Compare commits
7 Commits
23c602b07a
...
53a0010084
| Author | SHA1 | Date | |
|---|---|---|---|
| 53a0010084 | |||
| 4054629ad7 | |||
| 48afaeec3f | |||
| 8118f3e24e | |||
| 0254363d0c | |||
| 9fab2b0d89 | |||
| 8cc71f279d |
3
.gitignore
vendored
3
.gitignore
vendored
@ -482,3 +482,6 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# VSCode Files
|
||||
.vscode/*
|
||||
@ -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
|
||||
|
||||
60
README.md
60
README.md
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
9
src/Infrastructure/StreetDbContext.cs
Normal file
9
src/Infrastructure/StreetDbContext.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class StreetDbContext : DbContext
|
||||
{
|
||||
public StreetDbContext(DbContextOptions<StreetDbContext> options) : base(options)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@ -4,3 +4,5 @@ var app = builder.Build();
|
||||
app.MapGet("/", () => "Hello World!");
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program { }
|
||||
|
||||
201
tests/APITests.cs
Normal file
201
tests/APITests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
tests/StreetWebApplicationFactory.cs
Normal file
41
tests/StreetWebApplicationFactory.cs
Normal 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
33
tests/tests.csproj
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user