using Microsoft.EntityFrameworkCore; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Novels; namespace Novelly.Api.Tests; [TestFixture] public class CharacterServiceTests : ServiceTestFixture { private Guid _novelId; protected override void OnSetUp() { _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id; } [Test] public async Task New_characters_default_to_supporting_role_and_importance() { var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); Assert.Multiple(() => { Assert.That(character.Role, Is.EqualTo(CharacterRole.Supporting)); Assert.That(character.Importance, Is.EqualTo(CharacterImportance.Supporting)); }); } [Test] public async Task Promoting_a_character_to_main_sticks_until_changed_again() { var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var promoted = (await Characters.UpdateAsync( character.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!; Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main)); var afterUnrelatedUpdate = (await Characters.UpdateAsync( character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!; Assert.That(afterUnrelatedUpdate.Importance, Is.EqualTo(CharacterImportance.Main)); } [Test] public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() { var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest( "Ines", Motivation: "To find her sister.", Conflict: "Torn between vengeance and letting go.")); var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!; Assert.Multiple(() => { Assert.That(renamed.Name, Is.EqualTo("Ines Vell")); Assert.That(renamed.Motivation, Is.EqualTo("To find her sister.")); Assert.That(renamed.Conflict, Is.EqualTo("Torn between vengeance and letting go.")); }); var cleared = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Conflict: "")))!; Assert.Multiple(() => { Assert.That(cleared.Conflict, Is.Null); Assert.That(cleared.Motivation, Is.EqualTo("To find her sister.")); }); } [Test] public async Task Relating_characters_across_novels_is_refused() { var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); Assert.That( async () => await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")), Throws.TypeOf().With.Message.Contains("same novel")); } [Test] public async Task Removing_a_relationship_leaves_both_characters_in_place() { var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); var withRelationship = (await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(mara.Id, "sister")))!; var relationshipId = withRelationship.Relationships[0].Id; var removed = await Characters.RemoveRelationshipAsync(relationshipId); var afterRemoval = (await Characters.GetAsync(ines.Id))!; Assert.Multiple(() => { Assert.That(removed, Is.True); Assert.That(afterRemoval.Relationships, Is.Empty); }); } [Test] public async Task Adding_a_relationship_records_it_on_both_characters() { var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")); var inesAfter = (await Characters.GetAsync(ines.Id))!; var maraAfter = (await Characters.GetAsync(mara.Id))!; Assert.Multiple(() => { Assert.That(inesAfter.Relationships, Has.Count.EqualTo(1)); Assert.That(inesAfter.Relationships[0].RelationshipType, Is.EqualTo("sister")); Assert.That(inesAfter.Relationships[0].RelatedCharacterId, Is.EqualTo(mara.Id)); Assert.That(maraAfter.Relationships, Has.Count.EqualTo(1)); Assert.That(maraAfter.Relationships[0].RelationshipType, Is.EqualTo("brother")); Assert.That(maraAfter.Relationships[0].RelatedCharacterId, Is.EqualTo(ines.Id)); }); } [Test] public async Task A_relationship_with_no_reciprocal_type_mirrors_the_same_type_both_ways() { var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); await Characters.AddRelationshipAsync(ines.Id, new CreateRelationshipRequest(mara.Id, "rival")); var maraAfter = (await Characters.GetAsync(mara.Id))!; Assert.That(maraAfter.Relationships[0].RelationshipType, Is.EqualTo("rival")); } [Test] public async Task Removing_a_relationship_removes_the_reciprocal_side_too() { var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); var withRelationship = (await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")))!; var relationshipId = withRelationship.Relationships[0].Id; await Characters.RemoveRelationshipAsync(relationshipId); var maraAfter = (await Characters.GetAsync(mara.Id))!; Assert.That(maraAfter.Relationships, Is.Empty); } [Test] public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them() { var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map", CharacterIds: [ines.Id])); await Characters.DeleteAsync(ines.Id); using var verification = Db.CreateContext(); var survivingBeat = await verification.Beats.FirstAsync(b => b.Id == beat.Id); Assert.That(survivingBeat, Is.Not.Null); } [Test] public async Task Creating_a_character_under_a_missing_novel_returns_null_rather_than_throwing() => Assert.That( await Characters.CreateAsync(Guid.NewGuid(), new CreateCharacterRequest("Ines")), Is.Null); [Test] public async Task Reading_a_missing_character_returns_null_rather_than_throwing() => Assert.That(await Characters.GetAsync(Guid.NewGuid()), Is.Null); [Test] public async Task Aliases_round_trip_on_create_and_update() { var created = await Characters.CreateAsync( _novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"])); Assert.That(created.Aliases, Is.EqualTo(new[] { "The Grey Man", "Kael" })); var updated = (await Characters.UpdateAsync( created.Id, new UpdateCharacterRequest(Aliases: ["The Stranger"])))!; Assert.That(updated.Aliases, Is.EqualTo(new[] { "The Stranger" })); } [Test] public async Task Clearing_aliases_with_an_empty_list_empties_them() { var created = await Characters.CreateAsync( _novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"])); var cleared = (await Characters.UpdateAsync(created.Id, new UpdateCharacterRequest(Aliases: [])))!; Assert.That(cleared.Aliases, Is.Empty); } [Test] public async Task Linking_a_character_to_its_true_identity_records_it_on_both_sides() { var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); var linked = (await Characters.LinkIdentityAsync( stranger.Id, new LinkCharacterIdentityRequest(kael.Id, Note: "Same man, after the exile.")))!; Assert.Multiple(() => { Assert.That(linked.SameCharacterAsId, Is.EqualTo(kael.Id)); Assert.That(linked.SameCharacterAs?.Name, Is.EqualTo("Kael")); Assert.That(linked.IdentityNote, Is.EqualTo("Same man, after the exile.")); }); var canonical = (await Characters.GetAsync(kael.Id))!; Assert.That(canonical.OtherIdentities.Select(o => o.Id), Is.EqualTo(new[] { stranger.Id })); } [Test] public async Task Linking_to_a_character_that_is_itself_an_alias_flattens_to_the_canonical() { var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); var exile = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Exile")); await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)); var linked = (await Characters.LinkIdentityAsync(exile.Id, new LinkCharacterIdentityRequest(stranger.Id)))!; Assert.That(linked.SameCharacterAsId, Is.EqualTo(kael.Id)); } [Test] public async Task Linking_a_character_to_itself_is_rejected() { var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); Assert.That( async () => await Characters.LinkIdentityAsync(kael.Id, new LinkCharacterIdentityRequest(kael.Id)), Throws.TypeOf()); } [Test] public async Task Linking_identities_across_novels_is_refused() { var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); Assert.That( async () => await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)), Throws.TypeOf().With.Message.Contains("same novel")); } [Test] public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive() { var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)); await Characters.DeleteAsync(kael.Id); using var verification = Db.CreateContext(); var survivor = await verification.Characters.FirstAsync(c => c.Id == stranger.Id); Assert.That(survivor.SameCharacterAsId, Is.Null); } [Test] public async Task Unlinking_an_identity_clears_the_reveal_chapter_and_note() { var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Reveal")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); await Characters.LinkIdentityAsync( stranger.Id, new LinkCharacterIdentityRequest(kael.Id, chapter.Id, "Same man.")); var removed = await Characters.UnlinkIdentityAsync(stranger.Id); var after = (await Characters.GetAsync(stranger.Id))!; Assert.Multiple(() => { Assert.That(removed, Is.True); Assert.That(after.SameCharacterAsId, Is.Null); Assert.That(after.RevealedInChapterId, Is.Null); Assert.That(after.IdentityNote, Is.Null); }); } }