Compare commits

...

7 Commits

8 changed files with 354 additions and 1 deletions

3
.gitignore vendored
View File

@ -482,3 +482,6 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
# VSCode Files
.vscode/*

View File

@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PTV-Street", "src\PTV-Street.csproj", "{B1AF2E92-D6AD-4999-8341-60CE9A728C3B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tests", "tests\tests.csproj", "{FEB9535F-F5DF-43B1-B6B2-24D5D2102E78}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -18,5 +20,9 @@ Global
{B1AF2E92-D6AD-4999-8341-60CE9A728C3B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1AF2E92-D6AD-4999-8341-60CE9A728C3B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1AF2E92-D6AD-4999-8341-60CE9A728C3B}.Release|Any CPU.Build.0 = Release|Any CPU
{FEB9535F-F5DF-43B1-B6B2-24D5D2102E78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FEB9535F-F5DF-43B1-B6B2-24D5D2102E78}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FEB9535F-F5DF-43B1-B6B2-24D5D2102E78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FEB9535F-F5DF-43B1-B6B2-24D5D2102E78}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -1,10 +1,68 @@
# PTV Street Ressource Task
1. Create a service in dotnet. It has a REST API that serves a resource 'street'. A street can be created and deleted. It has a name, a geometry and a capacity (= how many vehicles can use it within a minute).
2. The street data is stored in a Postgres database, and we use EF core to save data there.
3. Implement an endpoint to add a given single point to the geometry of an existing street on either the beginning or the end, whatever fits better.
- Bonus: Note that this endpoint has a strange behavior from a user perspective when there are race conditions. Please take care of that in the implementation.
- Bonus: Note that this endpoint has a strange behavior from a user perspective when there are race conditions. Please take care of that in the implementation.
4. Add a hidden feature flag to decide whether the operation (in bullet point 3.) is done on the database level, using PostGis, or withing the backend code, algorithmically.
5. Add a Docker file and a Kubernetes manifest, so we can deploy it as a service with 3 replicas.
6. Also create a docker compose file, so we can locally check everything.
Authentication and API documentation is not part of this task.
## API
The following functions are supported:
- POST `/api/streets/`: Create new Street
Creates a new street when given the following format:
```JSON
{
"name": String,
"capacity": int,
"geometry": [
{
"x": int,
"y": int
}
]
}
```
- DELETE `/api/streets/<streetname>`: Delete a street
Deletes the street with the given name.
- GET `/api/streets/<streetname>`: Outputs a street
Returns the street with the given name in the following format:
```JSON
{
"name": String,
"capacity": int,
"geometry": [
{
"x": int,
"y": int
}
]
}
```
- PATCH `/api/streets/<streetname>`: Add a given point to a street
Adds a point to the end of the given street.
Optionally, one can specify the method used for this operation. This can be one of either `"Backend"`, `"Database"` or with `"PostGIS"`. If a different value is specified, an Error is returned.
```JSON
{
"point": {
"x": int,
"y": int
},
"method": "Backend" | "Database" | "PostGIS" // Optional, default is "Backend"
}
```

View File

@ -0,0 +1,9 @@
using Microsoft.EntityFrameworkCore;
public class StreetDbContext : DbContext
{
public StreetDbContext(DbContextOptions<StreetDbContext> options) : base(options)
{
}
}

View File

@ -4,3 +4,5 @@ var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
public partial class Program { }

201
tests/APITests.cs Normal file
View File

@ -0,0 +1,201 @@
using System.Net.Http.Json;
using FluentAssertions;
public class APITests : IClassFixture<StreetWebApplicationFactory<Program>>
{
private readonly HttpClient client;
public APITests(StreetWebApplicationFactory<Program> factory)
{
client = factory.CreateClient();
}
[Fact]
public async Task GET_Non_Existing_Street_Should_Return_NotFound()
{
var response = await client.GetAsync("/api/streets/Haid-und-Neu-Straße");
response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
[Fact]
public async Task GET_Street_Should_Return_POSTed_Street()
{
var street = new
{
name = "Kaiserstraße",
capacity = 100,
geometry = new[] { new { x = 10, y = 20 } }
};
var post_response = await client.PostAsJsonAsync("/api/streets/", street);
post_response.StatusCode.Should().Be(System.Net.HttpStatusCode.Created);
var get_response = await client.GetAsync("/api/streets/Kaiserstraße");
get_response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
var responseContent = await get_response.Content.ReadFromJsonAsync<object>();
street.Should().BeEquivalentTo(responseContent);
}
[Fact]
public async Task POST_Existing_Street_Should_Return_Conflict()
{
var street = new
{
name = "Adenauerring",
capacity = 100,
geometry = new[] { new { x = 10, y = 20 } }
};
await client.PostAsJsonAsync("/api/streets/", street);
var duplicate_street = new
{
name = "Adenauerring",
capacity = 200,
geometry = new[] { new { x = 30, y = 40 } }
};
var response = await client.PostAsJsonAsync("/api/streets", duplicate_street);
response.StatusCode.Should().Be(System.Net.HttpStatusCode.Conflict);
}
[Fact]
public async Task DELETE_Non_Existing_Street_Should_Return_NotFound()
{
var response = await client.GetAsync("/api/streets/Englerstraße");
response.StatusCode.Should().Be(System.Net.HttpStatusCode.NoContent);
}
[Fact]
public async Task DELETE_Existing_Street_Should_Delete_Street()
{
var street = new
{
name = "Moltkestraße",
capacity = 100,
geometry = new[] { new { x = 10, y = 20 } }
};
await client.PostAsJsonAsync("/api/streets/", street);
var delete_response = await client.DeleteAsync("/api/streets/Moltkestraße");
delete_response.StatusCode.Should().Be(System.Net.HttpStatusCode.NoContent);
var get_response = await client.GetAsync("/api/streets/Moltkestraße");
get_response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
[Fact]
public async Task PATCH_Non_Existing_Street_Should_Return_NotFound()
{
var update = new
{
Point = new { x = 10, y = 40 }
};
var response = await client.PatchAsJsonAsync("/api/streets/Ludwig-Erhard-Allee", update);
response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
[Fact]
public async Task PATCH_Existing_Street_Should_Update_Street()
{
var street = new
{
name = "Ettlingerstraße",
capacity = 50,
geometry = new[] { new { x = 5, y = 3 } }
};
await client.PostAsJsonAsync("/api/streets/", street);
var update_no_method = new
{
Point = new { x = 15, y = 30 }
};
var patch_response_no_method = await client.PatchAsJsonAsync("/api/streets/Ettlingerstraße", update_no_method);
patch_response_no_method.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
var get_response_no_method = await client.GetAsync("/api/streets/Ettlingerstraße");
get_response_no_method.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
var content_no_method = get_response_no_method.Content.ReadFromJsonAsync<object>();
var expected_content_no_method = new
{
name = "Ettlingerstraße",
capacity = 50,
geometry = new[] { new { x = 5, y = 3 }, new { x = 15, y = 30 } }
};
expected_content_no_method.Should().BeEquivalentTo(content_no_method);
// Test with "Backend" method
var update_backend = new
{
Point = new { x = 20, y = 35 },
Method = "Backend"
};
var patch_response_backend = await client.PatchAsJsonAsync("/api/streets/Ettlingerstraße", update_backend);
patch_response_backend.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
// Test with "PostGIS" method
var update_postgis = new
{
Point = new { x = 25, y = 40 },
Method = "PostGIS"
};
var patch_response_postgis = await client.PatchAsJsonAsync("/api/streets/Ettlingerstraße", update_postgis);
patch_response_postgis.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
// Test with "Database" method
var update_database = new
{
Point = new { x = 30, y = 45 },
Method = "Database"
};
var patch_response_database = await client.PatchAsJsonAsync("/api/streets/Ettlingerstraße", update_database);
patch_response_database.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
var get_response_final = await client.GetAsync("/api/streets/Ettlingerstraße");
get_response_final.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
var content_final = await get_response_final.Content.ReadFromJsonAsync<object>();
var expected_content_final = new
{
name = "Ettlingerstraße",
capacity = 50,
geometry = new[]
{
new { x = 5, y = 3 },
new { x = 15, y = 30 },
new { x = 20, y = 35 },
new { x = 25, y = 40 },
new { x = 30, y = 45 }
}
};
expected_content_final.Should().BeEquivalentTo(content_final);
}
}

View File

@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
public class StreetWebApplicationFactory<Program> : WebApplicationFactory<Program>, IAsyncLifetime where Program : class
{
private readonly PostgreSqlContainer dbTestContainer;
public StreetWebApplicationFactory()
{
dbTestContainer = new PostgreSqlBuilder().WithImage("postgis/postgis:17-3.5").Build();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Use the test database
services.AddDbContext<StreetDbContext>(options => options.UseNpgsql(dbTestContainer.GetConnectionString()));
// Ensure the database is created before running the tests
using var scope = services.BuildServiceProvider().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<StreetDbContext>();
db.Database.EnsureCreated();
});
}
public async Task InitializeAsync()
{
await dbTestContainer.StartAsync();
}
async Task IAsyncLifetime.DisposeAsync()
{
await dbTestContainer.DisposeAsync();
}
}

33
tests/tests.csproj Normal file
View File

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="npgsql" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Testcontainers" Version="4.3.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.3.0" />
<PackageReference Include="Testcontainers.Xunit" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\PTV-Street.csproj" />
</ItemGroup>
</Project>