using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; public sealed class ContractApiTests { private const string PlayerId = "dev-local-1"; private const string TemplateId = "prototype_contract_clear_combat_pocket"; private const string EncounterId = "prototype_combat_pocket"; private const string SeedBucket = "2026-06-28-neo151"; private const string DisplayName = "Clear Combat Pocket (Repeat)"; private static readonly DateTimeOffset CompletedAt = new(2026, 6, 28, 12, 0, 0, TimeSpan.Zero); [Fact] public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerUnknown() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsync( "/game/players/missing-player/contracts/issue", JsonContent.Create(CreateIssueRequest("missing-player"))); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsync( "/game/players/%20/contracts/issue", JsonContent.Create(CreateIssueRequest(PlayerId))); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Theory] [InlineData("", SeedBucket)] [InlineData(" ", SeedBucket)] [InlineData(TemplateId, "")] [InlineData(TemplateId, " ")] public async Task PostContractIssue_ShouldReturnBadRequest_WhenRequiredSeedFieldsEmpty( string templateId, string seedBucket) { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create( new ContractIssueRequest { SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, PlayerId = PlayerId, TemplateId = templateId, SeedBucket = seedBucket, })); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task PostContractIssue_ShouldReturnBadRequest_WhenPlayerIdMismatch() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create(CreateIssueRequest("other-player"))); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task PostContractIssue_ShouldReturnBadRequest_WhenSchemaVersionMismatch() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create( new ContractIssueRequest { SchemaVersion = 99, PlayerId = PlayerId, TemplateId = TemplateId, SeedBucket = SeedBucket, })); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task PostContractIssue_ShouldReturnIssuedTrue_WhenPrototypeTemplateEligible() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId( PlayerId, TemplateId, SeedBucket); // Act var response = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create(CreateIssueRequest(PlayerId))); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal(ContractIssueResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.True(body.Issued); Assert.Null(body.ReasonCode); Assert.NotNull(body.Contract); Assert.Equal(expectedInstanceId, body.Contract!.ContractInstanceId); Assert.Equal(TemplateId, body.Contract.TemplateId); Assert.Equal(DisplayName, body.Contract.TemplateDisplayName); Assert.Equal(ContractListApi.StatusActive, body.Contract.Status); Assert.Equal(EncounterId, body.Contract.EncounterTemplateId); Assert.Equal(SeedBucket, body.Contract.SeedBucket); Assert.Null(body.Contract.CompletedAt); Assert.Null(body.Contract.CompletionRewardSummary); } [Fact] public async Task PostContractIssue_ShouldReturnIssuedFalse_WhenActiveContractExists() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var first = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create(CreateIssueRequest(PlayerId))); Assert.Equal(HttpStatusCode.OK, first.StatusCode); // Act var response = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create( new ContractIssueRequest { SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, PlayerId = PlayerId, TemplateId = TemplateId, SeedBucket = "2026-06-28-neo151-second", })); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.False(body!.Issued); Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, body.ReasonCode); Assert.NotNull(body.Contract); Assert.Equal(ContractListApi.StatusActive, body.Contract!.Status); } [Fact] public async Task GetContracts_ShouldReturnNotFound_WhenPlayerUnknown() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.GetAsync("/game/players/missing-player/contracts"); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task GetContracts_ShouldReturnEmptyList_WhenNoInstances() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal(ContractListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.Equal(PlayerId, body.PlayerId); Assert.Empty(body.Contracts); } [Fact] public async Task GetContracts_ShouldReturnActiveRowAfterIssue_WithEncounterObjectiveId() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var issue = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create(CreateIssueRequest(PlayerId))); Assert.Equal(HttpStatusCode.OK, issue.StatusCode); // Act var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); var row = Assert.Single(body!.Contracts); Assert.Equal(ContractListApi.StatusActive, row.Status); Assert.Equal(EncounterId, row.EncounterTemplateId); Assert.Null(row.CompletionRewardSummary); } [Fact] public async Task GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var deps = ResolveDependencies(factory); var issue = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create(CreateIssueRequest(PlayerId))); Assert.Equal(HttpStatusCode.OK, issue.StatusCode); var issueBody = await issue.Content.ReadFromJsonAsync(); Assert.NotNull(issueBody?.Contract); var instanceId = issueBody!.Contract!.ContractInstanceId; var completed = ContractCompletionOperations.TryCompleteOnEncounterClear( PlayerId, EncounterId, deps.InstanceStore, deps.OutcomeStore, deps.TemplateRegistry, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, deps.TimeProvider); Assert.True(completed.Success); // Act var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); var row = Assert.Single(body!.Contracts); Assert.Equal(instanceId, row.ContractInstanceId); Assert.Equal(ContractListApi.StatusCompleted, row.Status); Assert.NotNull(row.CompletedAt); AssertPrototypeCompletionRewardSummary(row.CompletionRewardSummary); } [Fact] public async Task GetContracts_ShouldReturnSameCompletionRewardSummary_WhenReplayed() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var deps = ResolveDependencies(factory); _ = await client.PostAsync( $"/game/players/{PlayerId}/contracts/issue", JsonContent.Create(CreateIssueRequest(PlayerId))); _ = ContractCompletionOperations.TryCompleteOnEncounterClear( PlayerId, EncounterId, deps.InstanceStore, deps.OutcomeStore, deps.TemplateRegistry, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, deps.TimeProvider); // Act var first = await client.GetAsync($"/game/players/{PlayerId}/contracts"); var second = await client.GetAsync($"/game/players/{PlayerId}/contracts"); // Assert var firstBody = await first.Content.ReadFromJsonAsync(); var secondBody = await second.Content.ReadFromJsonAsync(); Assert.NotNull(firstBody); Assert.NotNull(secondBody); AssertCompletionRewardSummariesEqual( Assert.Single(firstBody!.Contracts).CompletionRewardSummary, Assert.Single(secondBody!.Contracts).CompletionRewardSummary); } private static ContractIssueRequest CreateIssueRequest(string playerId) => new() { SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, PlayerId = playerId, TemplateId = TemplateId, SeedBucket = SeedBucket, }; private static void AssertPrototypeCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary) { Assert.NotNull(summary); var item = Assert.Single(summary!.ItemGrants); Assert.Equal("scrap_metal_bulk", item.ItemId); Assert.Equal(5, item.Quantity); var skill = Assert.Single(summary.SkillXpGrants); Assert.Equal("salvage", skill.SkillId); Assert.Equal(15, skill.Amount); Assert.Empty(summary.ReputationGrants); } private static void AssertCompletionRewardSummariesEqual( QuestCompletionRewardSummaryJson? first, QuestCompletionRewardSummaryJson? second) { Assert.NotNull(first); Assert.NotNull(second); AssertPrototypeCompletionRewardSummary(first); AssertPrototypeCompletionRewardSummary(second); } private static ApiTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) { using var scope = factory.Services.CreateScope(); var services = scope.ServiceProvider; return new ApiTestDependencies( services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), new FakeTimeProvider(CompletedAt)); } private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider { public override DateTimeOffset GetUtcNow() => utcNow; } private sealed record ApiTestDependencies( IContractTemplateRegistry TemplateRegistry, IContractInstanceStore InstanceStore, IContractOutcomeStore OutcomeStore, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore, ISkillDefinitionRegistry SkillRegistry, IPlayerSkillProgressionStore SkillProgressionStore, ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, IReputationDeltaStore AuditStore, TimeProvider TimeProvider); }