tests/Application/CharacterServiceTests.cs

Unit tests for CharacterService in the application layer. Exercises creation, deletion, concurrency, reservation semantics, inventory initialization, validation of inputs and persisted type compatibility, and ensures no partial commits on failures.

File Access
#nullable enable

using Hexagon.V2.Application;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;

namespace Hexagon.V2.Tests.Application;

[TestClass]
public sealed class CharacterServiceTests
{
	[TestMethod]
	public async Task ClassCapacityUsesADurableReservationAndReleasesOnDeletion()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync( classCapacity: 1 );
		var service = environment.CreateCharacterService();
		var firstAccount = new AccountId( 9001 );
		var secondAccount = new AccountId( 9002 );

		var first = await service.CreateAsync( firstAccount, ApplicationServiceTestEnvironment.Request() );
		var full = await service.CreateAsync( secondAccount, ApplicationServiceTestEnvironment.Request() );
		Assert.IsTrue( first.Succeeded, first.Error?.Message );
		Assert.AreEqual( ErrorCode.Conflict, full.Error!.Code );
		Assert.AreEqual( 1, environment.Repositories.UniqueReservations.All().Count( value =>
			value.Value.Namespace == "hexagon.class-capacity" ) );

		Assert.IsTrue( (await service.DeleteAsync( firstAccount, first.Value.Character.Id )).Succeeded );
		var afterRelease = await service.CreateAsync( secondAccount, ApplicationServiceTestEnvironment.Request() );
		Assert.IsTrue( afterRelease.Succeeded, afterRelease.Error?.Message );
	}

	[TestMethod]
	public async Task ConcurrentClassCreationCannotExceedCapacity()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync( classCapacity: 1 );
		var service = environment.CreateCharacterService();

		var results = await Task.WhenAll(
			service.CreateAsync( new AccountId( 9101 ), ApplicationServiceTestEnvironment.Request() ).AsTask(),
			service.CreateAsync( new AccountId( 9102 ), ApplicationServiceTestEnvironment.Request() ).AsTask() );

		Assert.AreEqual( 1, results.Count( result => result.Succeeded ) );
		Assert.AreEqual( 1, results.Count( result => result.Error?.Code == ErrorCode.Conflict ) );
		Assert.HasCount( 1, environment.Repositories.Characters.All() );
	}

	[TestMethod]
	public async Task CreateAndDeleteCommitTheCompleteNestedAggregate()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var initializer = new TestCharacterInitializer("loadout", (_, _) =>
			OperationResult<CharacterInitializerContribution>.Success(new CharacterInitializerContribution
			{
				Items = new[]
				{
					new ItemGrantPlan
					{
						Definition = new DefinitionId(ApplicationServiceTestEnvironment.BagDefinitionId),
						Bag = new BagInventoryPlan
						{
							Width = 2,
							Height = 2,
							Items = new[]
							{
								new ItemGrantPlan
								{
									Definition = new DefinitionId(ApplicationServiceTestEnvironment.ItemDefinitionId)
								}
							}
						}
					}
				},
				Reservations = new[] { new UniqueReservationPlan("cid", "12345") }
			}));
		var service = environment.CreateCharacterService(new[] { initializer });
		var account = new AccountId(7656119);

		var created = await service.CreateAsync(account, ApplicationServiceTestEnvironment.Request());

		Assert.IsTrue(created.Succeeded, created.Error?.Message);
		Assert.AreEqual(account, created.Value.Character.AccountId);
		Assert.AreEqual(0, created.Value.Character.Slot);
		Assert.AreEqual("Alyx Vance", created.Value.Character.Name);
		Assert.AreEqual(new ClassId("worker"), created.Value.Character.Class);
		Assert.AreEqual(25L, created.Value.Character.Balance);
		Assert.HasCount(2, created.Value.Items);
		Assert.HasCount(1, environment.Repositories.Characters.All());
		Assert.HasCount(1, environment.Repositories.CharacterSlots.All());
		Assert.HasCount(2, environment.Repositories.Inventories.All());
		Assert.HasCount(2, environment.Repositories.OwnerInventories.All());
		Assert.HasCount(2, environment.Repositories.Items.All());
		Assert.HasCount(1, environment.Repositories.UniqueReservations.All());

		var deleted = await service.DeleteAsync(account, created.Value.Character.Id);

		Assert.IsTrue(deleted.Succeeded, deleted.Error?.Message);
		Assert.IsEmpty(environment.Repositories.Characters.All());
		Assert.IsEmpty(environment.Repositories.CharacterSlots.All());
		Assert.IsEmpty(environment.Repositories.Inventories.All());
		Assert.IsEmpty(environment.Repositories.OwnerInventories.All());
		Assert.IsEmpty(environment.Repositories.Items.All());
		Assert.IsEmpty(environment.Repositories.UniqueReservations.All());
	}

	[TestMethod]
	public async Task CreateSelectsTheLowestFreeOwnerSlot()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId(7656120);
		var first = ApplicationServiceTestEnvironment.Character(account, 0);
		var third = ApplicationServiceTestEnvironment.Character(account, 2);
		await environment.SeedAsync(unitOfWork =>
		{
			SeedCharacter(unitOfWork, environment, first);
			SeedCharacter(unitOfWork, environment, third);
		});
		var service = environment.CreateCharacterService();

		var created = await service.CreateAsync(account, ApplicationServiceTestEnvironment.Request());

		Assert.IsTrue(created.Succeeded, created.Error?.Message);
		Assert.AreEqual(1, created.Value.Character.Slot);
		CollectionAssert.AreEqual(
			new[] { 0, 1, 2 },
			service.ListForAccount(account).Select(character => character.Slot).ToArray());
	}

	[TestMethod]
	public async Task ListForAccountProbesSlotsInOrderWithoutScanningTheCharacterTable()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId(7656122);
		var high = ApplicationServiceTestEnvironment.Character(account, CharacterRules.MaximumSlots - 1);
		var middle = ApplicationServiceTestEnvironment.Character(account, 5);
		var zero = ApplicationServiceTestEnvironment.Character(account, 0);
		var otherAccount = ApplicationServiceTestEnvironment.Character(new AccountId(999), 1);
		await environment.SeedAsync(unitOfWork =>
		{
			SeedCharacter(unitOfWork, environment, high);
			SeedCharacter(unitOfWork, environment, middle);
			SeedCharacter(unitOfWork, environment, zero);
			SeedCharacter(unitOfWork, environment, otherAccount);
		});
		var service = environment.CreateCharacterService();

		var listed = service.ListForAccount(account);

		CollectionAssert.AreEqual(
			new[] { 0, 5, CharacterRules.MaximumSlots - 1 },
			listed.Select(character => character.Slot).ToArray());
		Assert.AreEqual(0, environment.Provider.AllCallCount(DomainCollections.Characters),
			"Account listing must use keyed slot probes, never a character-table scan.");
	}

	[TestMethod]
	public async Task CreateConflictsWhenEverySlotIsOccupied()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId(7656123);
		await environment.SeedAsync(unitOfWork =>
		{
			for (var slot = 0; slot < CharacterRules.MaximumSlots; slot++)
				SeedCharacter(unitOfWork, environment, ApplicationServiceTestEnvironment.Character(account, slot));
		});
		var service = environment.CreateCharacterService();

		var created = await service.CreateAsync(account, ApplicationServiceTestEnvironment.Request());

		Assert.AreEqual(ErrorCode.Conflict, created.Error!.Code);
		Assert.HasCount(CharacterRules.MaximumSlots, service.ListForAccount(account));
	}

	[TestMethod]
	public async Task CreationFieldsAreStrictlyAllowlistedRequiredAndTyped()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var service = environment.CreateCharacterService();
		var account = new AccountId(7656121);
		var unknown = ApplicationServiceTestEnvironment.Request(
			new Dictionary<string, CreationValue>(StringComparer.Ordinal)
			{
				["nickname"] = CreationValue.String("alyx"),
				["money"] = CreationValue.Integer(long.MaxValue)
			});
		var wrongType = ApplicationServiceTestEnvironment.Request(
			new Dictionary<string, CreationValue>(StringComparer.Ordinal)
			{
				["nickname"] = CreationValue.Integer(42)
			});
		var missing = ApplicationServiceTestEnvironment.Request(
			new Dictionary<string, CreationValue>(StringComparer.Ordinal));

		var unknownResult = await service.CreateAsync(account, unknown);
		var wrongTypeResult = await service.CreateAsync(account, wrongType);
		var missingResult = await service.CreateAsync(account, missing);

		Assert.AreEqual(ErrorCode.InvalidArgument, unknownResult.Error!.Code);
		Assert.AreEqual(ErrorCode.InvalidArgument, wrongTypeResult.Error!.Code);
		Assert.AreEqual(ErrorCode.InvalidArgument, missingResult.Error!.Code);
		Assert.IsEmpty(environment.Repositories.Characters.All());
		Assert.IsEmpty(environment.Repositories.Inventories.All());
	}

	[TestMethod]
	public async Task FullInitialInventoryRejectsTheWholeCreationBeforeCommit()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var initializer = new TestCharacterInitializer("oversized_loadout", (_, _) =>
			OperationResult<CharacterInitializerContribution>.Success(new CharacterInitializerContribution
			{
				Items = new[]
				{
					new ItemGrantPlan { Definition = new DefinitionId(ApplicationServiceTestEnvironment.ItemDefinitionId) },
					new ItemGrantPlan { Definition = new DefinitionId(ApplicationServiceTestEnvironment.ItemDefinitionId) }
				}
			}));
		var service = environment.CreateCharacterService(new[] { initializer }, 1, 1);

		var result = await service.CreateAsync(
			new AccountId(7656122),
			ApplicationServiceTestEnvironment.Request());

		Assert.IsTrue(result.Failed);
		Assert.AreEqual(ErrorCode.Conflict, result.Error!.Code);
		AssertAllCreationCollectionsEmpty(environment);
	}

	[TestMethod]
	public async Task InjectedCommitFailurePublishesNoPartialCreation()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var initializer = new TestCharacterInitializer("loadout", (_, _) =>
			OperationResult<CharacterInitializerContribution>.Success(new CharacterInitializerContribution
			{
				Items = new[]
				{
					new ItemGrantPlan { Definition = new DefinitionId(ApplicationServiceTestEnvironment.ItemDefinitionId) }
				},
				Reservations = new[] { new UniqueReservationPlan("cid", "67890") }
			}));
		var service = environment.CreateCharacterService(new[] { initializer });
		environment.Provider.FailNextCommit();

		var result = await service.CreateAsync(
			new AccountId(7656123),
			ApplicationServiceTestEnvironment.Request());

		Assert.IsTrue(result.Failed);
		Assert.AreEqual(ErrorCode.InternalError, result.Error!.Code);
		AssertAllCreationCollectionsEmpty(environment);
	}

	[TestMethod]
	public async Task DeleteRemovesCharacterReferencesButRetainsSeparateWorldBagGraph()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId(7656124);
		var character = ApplicationServiceTestEnvironment.Character(account, 0);
		var mainInventory = ApplicationServiceTestEnvironment.Inventory(InventoryOwner.Character(character.Id));
		var worldBag = ApplicationServiceTestEnvironment.Item(ApplicationServiceTestEnvironment.BagDefinitionId);
		var nestedItem = ApplicationServiceTestEnvironment.Item();
		var worldBagInventory = ApplicationServiceTestEnvironment.Inventory(
			InventoryOwner.ParentItem(worldBag.Id),
			new[] { new InventoryPlacement(nestedItem.Id, 0, 0) });
		var otherCharacter = CharacterId.New();
		await environment.SeedAsync(unitOfWork =>
		{
			SeedCharacter(unitOfWork, environment, character);
			unitOfWork.Create(
				environment.Repositories.Inventories,
				DomainKeys.Inventory(mainInventory.Id),
				mainInventory);
			unitOfWork.Create(
				environment.Repositories.OwnerInventories,
				DomainKeys.OwnerInventory(mainInventory.Owner, "main"),
				new OwnerInventoryRecord
				{
					Role = "main",
					Owner = mainInventory.Owner,
					InventoryId = mainInventory.Id
				});
			foreach (var item in new[] { worldBag, nestedItem })
				unitOfWork.Create(environment.Repositories.Items, DomainKeys.Item(item.Id), item);
			unitOfWork.Create(
				environment.Repositories.Inventories,
				DomainKeys.Inventory(worldBagInventory.Id),
				worldBagInventory);
			unitOfWork.Create(
				environment.Repositories.OwnerInventories,
				DomainKeys.OwnerInventory(worldBagInventory.Owner, "bag"),
				new OwnerInventoryRecord
				{
					Role = "bag",
					Owner = worldBagInventory.Owner,
					InventoryId = worldBagInventory.Id
				});
			unitOfWork.Create(
				environment.Repositories.WorldItems,
				DomainKeys.WorldItem(worldBag.Id),
				new WorldItemRecord
				{
					ItemId = worldBag.Id,
					Transform = ApplicationServiceTestEnvironment.Transform()
				});
			unitOfWork.Create(
				environment.Repositories.CharacterReferences,
				"primary",
				Reference("door", character.Id, null));
			unitOfWork.Create(
				environment.Repositories.CharacterReferences,
				"related",
				Reference("recognition", otherCharacter, character.Id));
			unitOfWork.Create(
				environment.Repositories.CharacterReferences,
				"unrelated",
				Reference("recognition", otherCharacter, CharacterId.New()));
		});

		var result = await environment.CreateCharacterService().DeleteAsync(account, character.Id);

		Assert.IsTrue(result.Succeeded, result.Error?.Message);
		Assert.IsEmpty(environment.Repositories.Characters.All());
		Assert.IsEmpty(environment.Repositories.CharacterSlots.All());
		Assert.HasCount(1, environment.Repositories.CharacterReferences.All());
		Assert.AreEqual("unrelated", environment.Repositories.CharacterReferences.All()[0].Key);
		Assert.HasCount(1, environment.Repositories.WorldItems.All());
		Assert.HasCount(2, environment.Repositories.Items.All());
		Assert.HasCount(1, environment.Repositories.Inventories.All());
		Assert.AreEqual(worldBagInventory.Id, environment.Repositories.Inventories.All()[0].Value.Id);
		Assert.HasCount(1, environment.Repositories.OwnerInventories.All());
	}

	[TestMethod]
	public async Task ExistingUniqueReservationRejectsCreationAtomically()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		await environment.SeedAsync(unitOfWork =>
			unitOfWork.Create(
				environment.Repositories.UniqueReservations,
				DomainKeys.UniqueReservation("cid", "12345"),
				new UniqueReservationRecord
				{
					Namespace = "cid",
					Value = "12345",
					CharacterId = CharacterId.New()
				}));
		var initializer = new TestCharacterInitializer("duplicate_cid", (_, _) =>
			OperationResult<CharacterInitializerContribution>.Success(new CharacterInitializerContribution
			{
				Items = new[]
				{
					new ItemGrantPlan { Definition = new DefinitionId(ApplicationServiceTestEnvironment.ItemDefinitionId) }
				},
				Reservations = new[] { new UniqueReservationPlan("cid", "12345") }
			}));

		var result = await environment.CreateCharacterService(new[] { initializer }).CreateAsync(
			new AccountId(7656125),
			ApplicationServiceTestEnvironment.Request());

		Assert.AreEqual(ErrorCode.Conflict, result.Error!.Code);
		Assert.IsEmpty(environment.Repositories.Characters.All());
		Assert.IsEmpty(environment.Repositories.Inventories.All());
		Assert.IsEmpty(environment.Repositories.Items.All());
		Assert.HasCount(1, environment.Repositories.UniqueReservations.All());
	}

	[TestMethod]
	public async Task UnknownOrIncompatibleStateAndTraitTypesFailBeforeCommit()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId(7656126);
		var unknownState = environment.CreateCharacterService(
			stateFactory: new StaticStateFactory(
				ApplicationServiceTestEnvironment.StatePayload(typeId: "unknown.state")));
		var incompatibleState = environment.CreateCharacterService(
			stateFactory: new StaticStateFactory(
				ApplicationServiceTestEnvironment.StatePayload(version: 2)));
		var unknownTrait = TraitInitializer(
			ApplicationServiceTestEnvironment.StatePayload(typeId: "unknown.trait"));
		var incompatibleTrait = TraitInitializer(
			ApplicationServiceTestEnvironment.StatePayload(version: 2));

		var unknownStateResult = await unknownState.CreateAsync(
			account, ApplicationServiceTestEnvironment.Request());
		var incompatibleStateResult = await incompatibleState.CreateAsync(
			account, ApplicationServiceTestEnvironment.Request());
		var unknownTraitResult = await environment.CreateCharacterService(new[] { unknownTrait }).CreateAsync(
			account, ApplicationServiceTestEnvironment.Request());
		var incompatibleTraitResult = await environment.CreateCharacterService(new[] { incompatibleTrait }).CreateAsync(
			account, ApplicationServiceTestEnvironment.Request());

		foreach (var result in new[]
			{ unknownStateResult, incompatibleStateResult, unknownTraitResult, incompatibleTraitResult })
		{
			Assert.AreEqual(ErrorCode.PersistedTypeInvalid, result.Error!.Code);
		}
		AssertAllCreationCollectionsEmpty(environment);
	}

	private static void SeedCharacter(
		Hexagon.V2.Persistence.IUnitOfWork unitOfWork,
		ApplicationServiceTestEnvironment environment,
		CharacterRecord character)
	{
		unitOfWork.Create(
			environment.Repositories.Characters,
			DomainKeys.Character(character.Id),
			character);
		unitOfWork.Create(
			environment.Repositories.CharacterSlots,
			DomainKeys.CharacterSlot(character.AccountId, character.Slot),
			new CharacterSlotRecord
			{
				AccountId = character.AccountId,
				Slot = character.Slot,
				CharacterId = character.Id
			});
		unitOfWork.Create(
			environment.Repositories.CharacterLifecycleGuards,
			DomainKeys.CharacterLifecycleGuard(character.Id),
			new CharacterLifecycleGuardRecord { CharacterId = character.Id, ReferenceRevision = 0 });
	}

	private static void AssertAllCreationCollectionsEmpty(ApplicationServiceTestEnvironment environment)
	{
		Assert.IsEmpty(environment.Repositories.Characters.All());
		Assert.IsEmpty(environment.Repositories.CharacterSlots.All());
		Assert.IsEmpty(environment.Repositories.CharacterLifecycleGuards.All());
		Assert.IsEmpty(environment.Repositories.Inventories.All());
		Assert.IsEmpty(environment.Repositories.OwnerInventories.All());
		Assert.IsEmpty(environment.Repositories.Items.All());
		Assert.IsEmpty(environment.Repositories.UniqueReservations.All());
	}

	private static CharacterReferenceRecord Reference(
		string category,
		CharacterId characterId,
		CharacterId? relatedCharacterId) => new()
	{
		Category = category,
		CharacterId = characterId,
		RelatedCharacterId = relatedCharacterId,
		State = ApplicationServiceTestEnvironment.StatePayload()
	};

	private static TestCharacterInitializer TraitInitializer(TypedPayload payload) => new(
		"trait",
		(_, _) => OperationResult<CharacterInitializerContribution>.Success(
			new CharacterInitializerContribution
			{
				Items = new[]
				{
					new ItemGrantPlan
					{
						Definition = new DefinitionId(ApplicationServiceTestEnvironment.ItemDefinitionId),
						Traits = new Dictionary<string, TypedPayload>(StringComparer.Ordinal)
						{
							["state"] = payload
						}
					}
				}
			}));

	private sealed class StaticStateFactory : ICharacterStateFactory
	{
		private readonly TypedPayload _state;
		public StaticStateFactory(TypedPayload state) => _state = state;

		public OperationResult<CharacterStatePlan> Create(CharacterCreationContext context) =>
			OperationResult<CharacterStatePlan>.Success(new CharacterStatePlan(_state, 0));
	}
}