using System.Linq; using System.Net; using System.Net.Http.Json; using NeonSprawl.Server.Game.Skills; using Xunit; namespace NeonSprawl.Server.Tests.Game.Skills; /// Regression tests for POST …/skill-progression (NEO-38). NEO-40 adds comment-only hook sites; JSON contract unchanged. E2.M2 implementation-alignment row documents NEO-40 landed. public sealed class SkillProgressionGrantApiTests { private static SkillProgressionGrantRequest Grant( string skillId, int schemaVersion, int amount = 50, string sourceKind = "activity") => new SkillProgressionGrantRequest { SchemaVersion = schemaVersion, SkillId = skillId, Amount = amount, SourceKind = sourceKind, }; private static SkillProgressionGrantRequest ValidGrant(string skillId, int amount = 50, string sourceKind = "activity") => Grant(skillId, SkillProgressionGrantRequest.CurrentSchemaVersion, amount, sourceKind); [Fact] public async Task PostSkillProgression_ShouldReturnBadRequest_WhenSchemaVersionMismatch() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var request = Grant("salvage", SkillProgressionGrantRequest.CurrentSchemaVersion + 42, amount: 1); // Act var response = await client.PostAsJsonAsync("/game/players/dev-local-1/skill-progression", request); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task PostSkillProgression_ShouldReturnNotFound_WhenPlayerUnknown() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/missing-player/skill-progression", ValidGrant("salvage")); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task PostSkillProgression_ShouldDenyUnknownSkill_WithReasonUnknownSkill_AndEchoProgression() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/skill-progression", ValidGrant("not-a-prototype-skill")); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var envelope = await response.Content.ReadFromJsonAsync(); Assert.NotNull(envelope); Assert.False(envelope!.Granted); Assert.Equal(SkillProgressionSnapshotApi.ReasonUnknownSkill, envelope.ReasonCode); Assert.NotNull(envelope.Progression); var salvage = envelope.Progression!.Skills!.Single(static s => s.Id == "salvage"); Assert.Equal(0, salvage.Xp); Assert.Equal(1, salvage.Level); } [Fact] public async Task PostSkillProgression_ShouldDenyTrainerOnSalvage_WithReasonSourceKindNotAllowed() { // Arrange — prototype_skills: salvage allowedXpSourceKinds = activity, mission_reward only await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/skill-progression", ValidGrant("salvage", amount: 10, sourceKind: "trainer")); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var envelope = await response.Content.ReadFromJsonAsync(); Assert.NotNull(envelope); Assert.False(envelope!.Granted); Assert.Equal(SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed, envelope.ReasonCode); } [Fact] public async Task PostSkillProgression_ShouldDenyNonPositiveAmount_WithReasonInvalidAmount() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/skill-progression", ValidGrant("salvage", amount: 0)); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var envelope = await response.Content.ReadFromJsonAsync(); Assert.NotNull(envelope); Assert.False(envelope!.Granted); Assert.Equal(SkillProgressionSnapshotApi.ReasonInvalidAmount, envelope.ReasonCode); } [Fact] public async Task PostSkillProgression_ShouldApplyGrant_AndGetMatches_WhenTrainerAllowedForRefine() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var post = await client.PostAsJsonAsync( "/game/players/dev-local-1/skill-progression", ValidGrant("refine", amount: 25, sourceKind: "trainer")); // Assert Assert.Equal(HttpStatusCode.OK, post.StatusCode); var envelope = await post.Content.ReadFromJsonAsync(); Assert.NotNull(envelope); Assert.True(envelope!.Granted); Assert.Empty(envelope.LevelUps); Assert.Single(envelope.Progression!.Skills!, static s => s.Id == "refine" && s.Xp == 25 && s.Level == 1); var get = await client.GetFromJsonAsync( "/game/players/dev-local-1/skill-progression"); Assert.NotNull(get); var refine = Assert.Single(get!.Skills!, static s => s.Id == "refine"); Assert.Equal(25, refine.Xp); Assert.Equal(1, refine.Level); } [Fact] public async Task PostSkillProgression_ShouldReportLevelUps_WhenThresholdCrossed() { // Arrange — content curve (prototype_level_curve.json): +100 xp from 0 → level 2 await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var post = await client.PostAsJsonAsync( "/game/players/dev-local-1/skill-progression", ValidGrant("intrusion", amount: 100, sourceKind: "activity")); // Assert var envelope = await post.Content.ReadFromJsonAsync(); Assert.NotNull(envelope); Assert.True(envelope!.Granted); var up = Assert.Single(envelope.LevelUps!); Assert.Equal("intrusion", up.SkillId); Assert.Equal(1, up.PreviousLevel); Assert.Equal(2, up.NewLevel); Assert.Single(envelope.Progression!.Skills!, static s => s.Id == "intrusion" && s.Xp == 100 && s.Level == 2); } [Fact] public async Task PostSkillProgression_ShouldAcceptSourceKind_WithIgnoreCase_OnAllowedList() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); // Act var post = await client.PostAsJsonAsync( "/game/players/dev-local-1/skill-progression", ValidGrant("salvage", amount: 5, sourceKind: "ACTIVITY")); // Assert var envelope = await post.Content.ReadFromJsonAsync(); Assert.NotNull(envelope); Assert.True(envelope!.Granted); } }