using System.Net; using System.Text; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Tests; using Microsoft.Extensions.Logging.Abstractions; using NeonSprawl.Server.Game.Skills; using Xunit; namespace NeonSprawl.Server.Tests.Game.Skills; public class SkillDefinitionCatalogLoaderTests { private const string ValidPrototypeCatalogJson = """ { "schemaVersion": 1, "skills": [ { "id": "salvage", "category": "gather", "displayName": "Salvage", "allowedXpSourceKinds": ["activity", "mission_reward"] }, { "id": "refine", "category": "process", "displayName": "Refine", "allowedXpSourceKinds": ["activity", "mission_reward", "trainer"] }, { "id": "intrusion", "category": "tech", "displayName": "Intrusion", "allowedXpSourceKinds": ["activity", "mission_reward", "book_or_item"] } ] } """; private static (string Root, string SkillsDir, string SchemaPath) CreateTempContentLayout() { var root = Directory.CreateTempSubdirectory("neon-sprawl-skillcat-"); var skillsDir = Path.Combine(root.FullName, "content", "skills"); var schemaDir = Path.Combine(root.FullName, "content", "schemas"); Directory.CreateDirectory(skillsDir); Directory.CreateDirectory(schemaDir); var schemaPath = Path.Combine(schemaDir, "skill-def.schema.json"); File.Copy(SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(), schemaPath, overwrite: true); return (root.FullName, skillsDir, schemaPath); } [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange var (_, skillsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText(Path.Combine(skillsDir, "prototype.json"), ValidPrototypeCatalogJson, Encoding.UTF8); // Act var catalog = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance); // Assert Assert.Equal(3, catalog.DistinctSkillCount); Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.True(catalog.ById.ContainsKey("salvage")); Assert.Equal("gather", catalog.ById["salvage"].Category); } [Fact] public void Load_ShouldThrow_WhenSkillsIsNotArray() { // Arrange var (_, skillsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText(Path.Combine(skillsDir, "bad.json"), """{"skills": "nope"}""", Encoding.UTF8); // Act var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("expected top-level 'skills' array", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRowViolatesSchema() { // Arrange var (_, skillsDir, schemaPath) = CreateTempContentLayout(); var bad = """ { "skills": [ { "id": "salvage", "category": "gather", "displayName": "", "allowedXpSourceKinds": ["activity"] } ] } """; File.WriteAllText(Path.Combine(skillsDir, "bad.json"), bad, Encoding.UTF8); // Act var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("Skill catalog validation failed", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles() { // Arrange var (_, skillsDir, schemaPath) = CreateTempContentLayout(); var singleSalvage = """ { "skills": [ { "id": "salvage", "category": "gather", "displayName": "Salvage", "allowedXpSourceKinds": ["activity"] } ] } """; File.WriteAllText(Path.Combine(skillsDir, "a.json"), singleSalvage, Encoding.UTF8); File.WriteAllText(Path.Combine(skillsDir, "b.json"), singleSalvage, Encoding.UTF8); // Act var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate skill id", ioe.Message, StringComparison.Ordinal); Assert.Contains("salvage", ioe.Message, StringComparison.Ordinal); Assert.Contains("a.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("b.json", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSlice1IdsIncomplete() { // Arrange var (_, skillsDir, schemaPath) = CreateTempContentLayout(); var twoOnly = """ { "skills": [ { "id": "salvage", "category": "gather", "displayName": "Salvage", "allowedXpSourceKinds": ["activity"] }, { "id": "refine", "category": "process", "displayName": "Refine", "allowedXpSourceKinds": ["activity"] } ] } """; File.WriteAllText(Path.Combine(skillsDir, "partial.json"), twoOnly, Encoding.UTF8); // Act var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype Slice 1 expects exactly skill ids", ioe.Message, StringComparison.Ordinal); } [Fact] public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds() { // Arrange — InMemoryWebApplicationFactory strips Postgres and pins Content:SkillsDirectory to the repo catalog. await using var factory = new InMemoryWebApplicationFactory(); using var client = factory.CreateClient(); // Act var response = await client.GetAsync("/health"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var catalog = factory.Services.GetRequiredService(); Assert.Equal(3, catalog.DistinctSkillCount); } [Fact] public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() { // Arrange var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-skills-" + Guid.NewGuid().ToString("n")); Directory.CreateDirectory(badDir); // Act var ex = Record.Exception(() => { using var factory = new WebApplicationFactory().WithWebHostBuilder(b => b.UseSetting("Content:SkillsDirectory", badDir)); factory.CreateClient(); }); // Assert Assert.NotNull(ex); Assert.Contains("Skill catalog validation failed", ex.ToString(), StringComparison.Ordinal); } }