diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs new file mode 100644 index 0000000..88eb1c1 --- /dev/null +++ b/GameList.Tests/AuthTests.cs @@ -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(); + 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); + } +} diff --git a/GameList.Tests/GameList.Tests.csproj b/GameList.Tests/GameList.Tests.csproj new file mode 100644 index 0000000..4c1ef50 --- /dev/null +++ b/GameList.Tests/GameList.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/GameList.Tests/ResultsTests.cs b/GameList.Tests/ResultsTests.cs new file mode 100644 index 0000000..d209e41 --- /dev/null +++ b/GameList.Tests/ResultsTests.cs @@ -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("/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()); + } +} diff --git a/GameList.Tests/StateTests.cs b/GameList.Tests/StateTests.cs new file mode 100644 index 0000000..40fe535 --- /dev/null +++ b/GameList.Tests/StateTests.cs @@ -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("/api/state"); + + Assert.Equal((int)Phase.Results, state.GetProperty("CurrentPhase").GetInt32()); + Assert.True(state.GetProperty("resultsOpen").GetBoolean()); + } +} diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs new file mode 100644 index 0000000..1a26a4f --- /dev/null +++ b/GameList.Tests/SuggestionTests.cs @@ -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); + } +} diff --git a/GameList.Tests/Support/StubHttpClientFactory.cs b/GameList.Tests/Support/StubHttpClientFactory.cs new file mode 100644 index 0000000..98de9fa --- /dev/null +++ b/GameList.Tests/Support/StubHttpClientFactory.cs @@ -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); + } +} diff --git a/GameList.Tests/Support/StubHttpMessageHandler.cs b/GameList.Tests/Support/StubHttpMessageHandler.cs new file mode 100644 index 0000000..dfc8c7d --- /dev/null +++ b/GameList.Tests/Support/StubHttpMessageHandler.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace GameList.Tests.Support; + +internal class StubHttpMessageHandler : HttpMessageHandler +{ + private Func _responder; + + public StubHttpMessageHandler() + { + _responder = DefaultResponder; + } + + public void SetResponder(Func responder) + { + _responder = responder ?? DefaultResponder; + } + + private static HttpResponseMessage DefaultResponder(HttpRequestMessage _) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Array.Empty()) + }; + response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + response.Content.Headers.ContentLength = 0; + return response; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_responder(request)); + } +} diff --git a/GameList.Tests/Support/TestClientExtensions.cs b/GameList.Tests/Support/TestClientExtensions.cs new file mode 100644 index 0000000..bce91e4 --- /dev/null +++ b/GameList.Tests/Support/TestClientExtensions.cs @@ -0,0 +1,46 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace GameList.Tests.Support; + +internal static class TestClientExtensions +{ + public static Task 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 LoginAsync(this HttpClient client, string username, string password) + { + return client.PostAsJsonAsync("/api/auth/login", new + { + Username = username, + Password = password + }); + } + + public static async Task 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(); + return json.GetProperty("Id").GetInt32(); + } +} diff --git a/GameList.Tests/Support/TestWebApplicationFactory.cs b/GameList.Tests/Support/TestWebApplicationFactory.cs new file mode 100644 index 0000000..63ea5c6 --- /dev/null +++ b/GameList.Tests/Support/TestWebApplicationFactory.cs @@ -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 +{ + private SqliteConnection? _connection; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ADMIN_PASSWORD"] = "admin-key" + }); + }); + + builder.ConfigureServices(services => + { + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) + { + services.Remove(descriptor); + } + + _connection = new SqliteConnection("Data Source=:memory:;Cache=Shared"); + _connection.Open(); + + services.AddDbContext(options => + { + options.UseSqlite(_connection); + }); + + services.AddSingleton(); + services.AddSingleton(); + }); + + 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(); + 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(); + + public Task WithDbContextAsync(Func action) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return action(db); + } + + public HttpClient CreateClientWithCookies() + { + return CreateClient(new WebApplicationFactoryClientOptions + { + HandleCookies = true, + AllowAutoRedirect = false + }); + } +} diff --git a/GameList.Tests/VoteTests.cs b/GameList.Tests/VoteTests.cs new file mode 100644 index 0000000..c34536b --- /dev/null +++ b/GameList.Tests/VoteTests.cs @@ -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>("/api/votes/mine"); + + Assert.Equal(2, mine.Count); + Assert.All(mine, v => Assert.Equal(9, v.Score)); + } + + private record VoteRecord(int SuggestionId, int Score); +} diff --git a/Program.cs b/Program.cs index ec0d732..322f49c 100644 --- a/Program.cs +++ b/Program.cs @@ -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 { }