using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;
/// Regression tests for POST …/craft (NEO-70).
public sealed class PlayerCraftApiTests
{
private const string PlayerId = "dev-local-1";
private static PlayerCraftRequest Request(
string recipeId,
int? quantity = null,
int schemaVersion = PlayerCraftRequest.CurrentSchemaVersion) =>
new()
{
SchemaVersion = schemaVersion,
RecipeId = recipeId,
Quantity = quantity,
};
[Fact]
public async Task PostCraft_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/craft",
Request("refine_scrap_standard"));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostCraft_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
Request("refine_scrap_standard", schemaVersion: 99));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostCraft_ShouldReturnBadRequest_WhenRecipeIdMissing()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
new PlayerCraftRequest { SchemaVersion = PlayerCraftRequest.CurrentSchemaVersion, RecipeId = " " });
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostCraft_RefineScrapStandard_ShouldReturnSuccessEnvelope()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
SeedStack(factory, "scrap_metal_bulk", 5);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
Request("refine_scrap_standard"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.NotNull(body);
Assert.Equal(CraftResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.True(body.Success);
Assert.Null(body.ReasonCode);
Assert.Single(body.InputsConsumed);
Assert.Equal("scrap_metal_bulk", body.InputsConsumed[0].ItemId);
Assert.Equal(5, body.InputsConsumed[0].Quantity);
Assert.Single(body.OutputsGranted);
Assert.Equal("refined_plate_stock", body.OutputsGranted[0].ItemId);
Assert.Equal(1, body.OutputsGranted[0].Quantity);
Assert.NotNull(body.XpGrantSummary);
Assert.Equal("refine", body.XpGrantSummary!.SkillId);
Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, body.XpGrantSummary.Amount);
Assert.Equal(RefineSkillXpConstants.ActivitySourceKind, body.XpGrantSummary.SourceKind);
var json = await response.Content.ReadAsStringAsync();
Assert.Contains("\"reasonCode\":null", json, StringComparison.Ordinal);
}
[Fact]
public async Task PostCraft_WhenQuantityOmitted_ShouldDefaultToOneAndSucceed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
SeedStack(factory, "scrap_metal_bulk", 5);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
new { schemaVersion = PlayerCraftRequest.CurrentSchemaVersion, recipeId = "refine_scrap_standard" });
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.NotNull(body);
Assert.True(body!.Success);
Assert.Single(body.InputsConsumed);
Assert.Equal(5, body.InputsConsumed[0].Quantity);
Assert.Single(body.OutputsGranted);
Assert.Equal(1, body.OutputsGranted[0].Quantity);
}
[Fact]
public async Task PostCraft_WhenMaterialsMissing_ShouldDenyWithInsufficientMaterials()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var beforeInventory = await client.GetFromJsonAsync(
$"/game/players/{PlayerId}/inventory");
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
Request("refine_scrap_standard"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.NotNull(body);
Assert.False(body!.Success);
Assert.Equal(CraftReasonCodes.InsufficientMaterials, body.ReasonCode);
Assert.Empty(body.InputsConsumed);
Assert.Empty(body.OutputsGranted);
Assert.Null(body.XpGrantSummary);
var afterInventory = await client.GetFromJsonAsync(
$"/game/players/{PlayerId}/inventory");
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
}
[Fact]
public async Task PostCraft_ForUnknownRecipe_ShouldDenyWithUnknownRecipe()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
Request("not_a_real_recipe"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.NotNull(body);
Assert.False(body!.Success);
Assert.Equal(CraftReasonCodes.UnknownRecipe, body.ReasonCode);
Assert.Empty(body.InputsConsumed);
Assert.Empty(body.OutputsGranted);
Assert.Null(body.XpGrantSummary);
var json = await response.Content.ReadAsStringAsync();
Assert.Contains("\"reasonCode\":\"unknown_recipe\"", json, StringComparison.Ordinal);
Assert.Contains("\"xpGrantSummary\":null", json, StringComparison.Ordinal);
}
[Fact]
public async Task PostCraft_WhenQuantityInvalid_ShouldDenyWithInvalidQuantity()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
SeedStack(factory, "scrap_metal_bulk", 5);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
Request("refine_scrap_standard", quantity: 0));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.NotNull(body);
Assert.False(body!.Success);
Assert.Equal(CraftReasonCodes.InvalidQuantity, body.ReasonCode);
Assert.Equal(5, CountBagItem(factory, "scrap_metal_bulk"));
}
[Fact]
public async Task PostCraft_MakePrototypeArmor_WhenEquipmentOccupied_ShouldDenyWithInventoryFull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
SeedStack(factory, "refined_plate_stock", 8);
SeedStack(factory, "scrap_metal_bulk", 5);
SeedStack(factory, "prototype_armor_shell", 1);
var beforeInventory = await client.GetFromJsonAsync(
$"/game/players/{PlayerId}/inventory");
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
Request("make_prototype_armor"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.NotNull(body);
Assert.False(body!.Success);
Assert.Equal(CraftReasonCodes.InventoryFull, body.ReasonCode);
var afterInventory = await client.GetFromJsonAsync(
$"/game/players/{PlayerId}/inventory");
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
}
[Fact]
public async Task PostCraft_WhenInventoryStoreMissing_ShouldReturnNotFound()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
for (var i = services.Count - 1; i >= 0; i--)
{
if (services[i].ServiceType == typeof(IPlayerInventoryStore))
{
services.RemoveAt(i);
}
}
services.AddSingleton();
});
});
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{PlayerId}/craft",
Request("refine_scrap_standard"));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public void MapResponse_ShouldReturnEmptyIoArrays_WhenCraftDenied()
{
// Arrange
var result = new CraftResult(
Success: false,
ReasonCode: CraftReasonCodes.UnknownRecipe,
InputsConsumed: [],
OutputsGranted: [],
XpGrantSummary: null);
// Act
var response = PlayerCraftApi.MapResponse(result);
// Assert
Assert.False(response.Success);
Assert.Equal(CraftReasonCodes.UnknownRecipe, response.ReasonCode);
Assert.Empty(response.InputsConsumed);
Assert.Empty(response.OutputsGranted);
Assert.Null(response.XpGrantSummary);
}
private static void SeedStack(InMemoryWebApplicationFactory factory, string itemId, int quantity)
{
var itemRegistry = factory.Services.GetRequiredService();
var inventoryStore = factory.Services.GetRequiredService();
var outcome = PlayerInventoryOperations.TryAddStack(
PlayerId,
itemId,
quantity,
itemRegistry,
inventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
}
private static int CountBagItem(InMemoryWebApplicationFactory factory, string itemId)
{
var inventoryStore = factory.Services.GetRequiredService();
inventoryStore.TryGetSnapshot(PlayerId, out var snapshot);
var total = 0;
foreach (var slot in snapshot!.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static int TotalBagQuantity(PlayerInventorySnapshotResponse snapshot)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
total += slot.Quantity;
}
return total;
}
private sealed class InventoryStoreAlwaysMissing : IPlayerInventoryStore
{
public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot)
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
}
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot) => false;
public bool TryMutateSnapshot(
string playerId,
Func mutator,
out PlayerInventorySnapshot result)
{
result = PlayerInventorySnapshot.Empty();
return false;
}
}
}