Add backend test harness with mock SQLite

This commit is contained in:
2026-02-05 17:46:56 +01:00
parent 87fa1974dd
commit 7e2d9ba9b8
11 changed files with 480 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
using System.Net;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GameList.Tests.Support;
namespace GameList.Tests;
public class AuthTests
{
[Fact]
public async Task Register_with_admin_key_sets_admin_flag()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var response = await client.RegisterAsync("adminuser", admin: true);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.GetProperty("IsAdmin").GetBoolean());
}
[Fact]
public async Task Register_duplicate_username_returns_conflict()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var first = await client.RegisterAsync("duplicate");
first.EnsureSuccessStatusCode();
var second = await client.RegisterAsync("duplicate");
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
[Fact]
public async Task Login_with_wrong_password_returns_unauthorized()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("player1");
var login = await client.LoginAsync("player1", "wrongpass");
Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode);
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\GameList.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
using System.Net.Http.Json;
using System.Text.Json;
using GameList.Tests.Support;
namespace GameList.Tests;
public class ResultsTests
{
[Fact]
public async Task Results_available_after_admin_unlocks()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
var suggestionId = await player.CreateSuggestionAsync("ResultGame");
await player.PostAsJsonAsync("/api/me/phase/next", new { });
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 8 });
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
await player.PostAsJsonAsync("/api/me/phase/next", new { });
var results = await player.GetFromJsonAsync<JsonElement>("/api/results");
Assert.True(results.GetArrayLength() >= 1);
var first = results[0];
Assert.Equal("ResultGame", first.GetProperty("Name").GetString());
Assert.Equal(8, (int)first.GetProperty("Average").GetDouble());
}
}

View File

@@ -0,0 +1,45 @@
using System.Net;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GameList.Domain;
using GameList.Tests.Support;
namespace GameList.Tests;
public class StateTests
{
[Fact]
public async Task Cannot_advance_to_results_when_locked()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("player");
var toVote = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { }));
toVote.EnsureSuccessStatusCode();
var toResults = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { }));
Assert.Equal(HttpStatusCode.BadRequest, toResults.StatusCode);
}
[Fact]
public async Task Admin_opening_results_moves_players_to_results_phase()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("user");
var toggle = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
toggle.EnsureSuccessStatusCode();
var state = await player.GetFromJsonAsync<JsonElement>("/api/state");
Assert.Equal((int)Phase.Results, state.GetProperty("CurrentPhase").GetInt32());
Assert.True(state.GetProperty("resultsOpen").GetBoolean());
}
}

View File

@@ -0,0 +1,71 @@
using System.Net;
using System.Net;
using System.Net.Http.Json;
using GameList.Tests.Support;
namespace GameList.Tests;
public class SuggestionTests
{
[Fact]
public async Task Player_cannot_exceed_five_suggestions()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("suggestor");
for (var i = 0; i < 5; i++)
{
var resp = await client.PostAsJsonAsync("/api/suggestions", new
{
Name = $"Game {i}",
Genre = (string?)null,
Description = (string?)null,
ScreenshotUrl = (string?)null,
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
resp.EnsureSuccessStatusCode();
}
var sixth = await client.PostAsJsonAsync("/api/suggestions", new
{
Name = "Overflow",
Genre = (string?)null,
Description = (string?)null,
ScreenshotUrl = (string?)null,
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
Assert.Equal(HttpStatusCode.BadRequest, sixth.StatusCode);
}
[Fact]
public async Task Unreachable_screenshot_url_is_rejected()
{
using var factory = new TestWebApplicationFactory();
factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest));
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("imgtester");
var response = await client.PostAsJsonAsync("/api/suggestions", new
{
Name = "Needs image",
Genre = (string?)null,
Description = (string?)null,
ScreenshotUrl = "http://example.com/image.png",
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

View File

@@ -0,0 +1,18 @@
using System.Net.Http;
namespace GameList.Tests.Support;
internal class StubHttpClientFactory : IHttpClientFactory
{
private readonly StubHttpMessageHandler _handler;
public StubHttpClientFactory(StubHttpMessageHandler handler)
{
_handler = handler;
}
public HttpClient CreateClient(string name)
{
return new HttpClient(_handler, dispose: false);
}
}

View File

@@ -0,0 +1,35 @@
using System.Net;
using System.Net.Http.Headers;
namespace GameList.Tests.Support;
internal class StubHttpMessageHandler : HttpMessageHandler
{
private Func<HttpRequestMessage, HttpResponseMessage> _responder;
public StubHttpMessageHandler()
{
_responder = DefaultResponder;
}
public void SetResponder(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
_responder = responder ?? DefaultResponder;
}
private static HttpResponseMessage DefaultResponder(HttpRequestMessage _)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Array.Empty<byte>())
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
response.Content.Headers.ContentLength = 0;
return response;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_responder(request));
}
}

View File

@@ -0,0 +1,46 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace GameList.Tests.Support;
internal static class TestClientExtensions
{
public static Task<HttpResponseMessage> RegisterAsync(this HttpClient client, string username, bool admin = false)
{
return client.PostAsJsonAsync("/api/auth/register", new
{
Username = username,
Password = "Pass123!",
DisplayName = $"{username}-name",
AdminKey = admin ? "admin-key" : null
});
}
public static Task<HttpResponseMessage> LoginAsync(this HttpClient client, string username, string password)
{
return client.PostAsJsonAsync("/api/auth/login", new
{
Username = username,
Password = password
});
}
public static async Task<int> CreateSuggestionAsync(this HttpClient client, string name)
{
var response = await client.PostAsJsonAsync("/api/suggestions", new
{
Name = name,
Genre = "Coop",
Description = (string?)null,
ScreenshotUrl = (string?)null,
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("Id").GetInt32();
}
}

View File

@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
using GameList.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace GameList.Tests.Support;
internal class TestWebApplicationFactory : WebApplicationFactory<Program>
{
private SqliteConnection? _connection;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ADMIN_PASSWORD"] = "admin-key"
});
});
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
_connection = new SqliteConnection("Data Source=:memory:;Cache=Shared");
_connection.Open();
services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlite(_connection);
});
services.AddSingleton<StubHttpMessageHandler>();
services.AddSingleton<IHttpClientFactory, StubHttpClientFactory>();
});
builder.UseSetting("https_port", "0");
}
protected override IHost CreateHost(IHostBuilder builder)
{
var host = base.CreateHost(builder);
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
db.Database.Migrate();
return host;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_connection?.Dispose();
}
}
public StubHttpMessageHandler HttpHandler => Services.GetRequiredService<StubHttpMessageHandler>();
public Task WithDbContextAsync(Func<AppDbContext, Task> action)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return action(db);
}
public HttpClient CreateClientWithCookies()
{
return CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
AllowAutoRedirect = false
});
}
}

View File

@@ -0,0 +1,61 @@
using System.Net;
using System.Net;
using System.Net.Http.Json;
using GameList.Tests.Support;
namespace GameList.Tests;
public class VoteTests
{
[Fact]
public async Task Finalizing_votes_blocks_further_changes()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("voter");
var suggestionId = await client.CreateSuggestionAsync("VoteGame");
await client.PostAsJsonAsync("/api/me/phase/next", new { });
var vote = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 7 });
vote.EnsureSuccessStatusCode();
var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
finalize.EnsureSuccessStatusCode();
var change = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = suggestionId, Score = 5 });
Assert.Equal(HttpStatusCode.BadRequest, change.StatusCode);
}
[Fact]
public async Task Linked_votes_apply_to_all_linked_suggestions()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("linker");
var id1 = await player.CreateSuggestionAsync("Game A");
var id2 = await player.CreateSuggestionAsync("Game B");
await player.PostAsJsonAsync("/api/me/phase/next", new { });
var linkResponse = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = id1, TargetSuggestionId = id2 });
linkResponse.EnsureSuccessStatusCode();
var vote = await player.PostAsJsonAsync("/api/votes", new { SuggestionId = id1, Score = 9 });
vote.EnsureSuccessStatusCode();
var mine = await player.GetFromJsonAsync<List<VoteRecord>>("/api/votes/mine");
Assert.Equal(2, mine.Count);
Assert.All(mine, v => Assert.Equal(9, v.Score));
}
private record VoteRecord(int SuggestionId, int Score);
}

View File

@@ -133,3 +133,5 @@ static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
// If we can't rewrite, continue; frontend can still be set manually.
}
}
public partial class Program { }