Add backend test harness with mock SQLite
This commit is contained in:
50
GameList.Tests/AuthTests.cs
Normal file
50
GameList.Tests/AuthTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
27
GameList.Tests/GameList.Tests.csproj
Normal file
27
GameList.Tests/GameList.Tests.csproj
Normal 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>
|
||||
35
GameList.Tests/ResultsTests.cs
Normal file
35
GameList.Tests/ResultsTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
45
GameList.Tests/StateTests.cs
Normal file
45
GameList.Tests/StateTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
71
GameList.Tests/SuggestionTests.cs
Normal file
71
GameList.Tests/SuggestionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
18
GameList.Tests/Support/StubHttpClientFactory.cs
Normal file
18
GameList.Tests/Support/StubHttpClientFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
35
GameList.Tests/Support/StubHttpMessageHandler.cs
Normal file
35
GameList.Tests/Support/StubHttpMessageHandler.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
46
GameList.Tests/Support/TestClientExtensions.cs
Normal file
46
GameList.Tests/Support/TestClientExtensions.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
90
GameList.Tests/Support/TestWebApplicationFactory.cs
Normal file
90
GameList.Tests/Support/TestWebApplicationFactory.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
61
GameList.Tests/VoteTests.cs
Normal file
61
GameList.Tests/VoteTests.cs
Normal 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);
|
||||
}
|
||||
@@ -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 { }
|
||||
|
||||
Reference in New Issue
Block a user