diff --git a/CLAUDE.md b/CLAUDE.md index 2ae4a1e4..7bc70cca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,6 +276,16 @@ public static MyEntity FromSnapshot(MySnapshot snapshot) => new() { ... }; - Stryker.NET with 80% high / 60% low / 50% break thresholds - Excludes: tests, migrations, ToString/GetHashCode/Equals +### Quality Bar (Mandatory, non-negotiable) + +La qualité prime sur tout le reste. Le temps de calcul ou la longueur de la session ne sont **jamais** une raison pour livrer en dessous de ce seuil. + +- **Chaque bounded context** (Domain *et* Application) doit atteindre **≥ 90 % de couverture de code** et **90–95 % de score de mutation**. Pas d'exception « partielle » : un BC laissé à 65 % est une régression à finir dans la même session, pas un acquis. +- **On ne raisonne pas en budget.** On ne clôt pas un BC « parce que c'est long ». On le finit. +- **Les seuls survivants tolérés** sont les mutants prouvablement **équivalents** (ex. `ArgumentNullException.ThrowIfNull` sur un chemin où un `null` lèverait de toute façon, garde rendue redondante par une validation suivante, `ThrowIfCancellationRequested`). Chaque survivant toléré doit être justifiable en une phrase. +- **Préférence sociable** : couvrir la logique atteignable via les use cases avec des tests parlant métier. Les tests VO/aggregate directs sont réservés aux invariants de frontière (VOs construits hors use case, méthodes d'aggregate sans use case pilote). +- **Décisions de scope** (exclure de `stryker-config.json`, supprimer une garde défensive non justifiée) sont permises quand elles sont documentées — jamais pour gonfler artificiellement le score. + --- ## Code Style diff --git a/coverlet.runsettings b/coverlet.runsettings index 06043d90..77e052ac 100644 --- a/coverlet.runsettings +++ b/coverlet.runsettings @@ -5,8 +5,8 @@ opencover - [*]*.Migrations.*,[*]*Tests* - **/Migrations/*.cs,**/Program.cs,**/AssemblyReference.cs + [*]*.Migrations.*,[*]*Tests*,[*.Infrastructure]* + **/Migrations/*.cs,**/Program.cs,**/AssemblyReference.cs,**/Errors/*Translations.cs,**/Mediator/**/*.cs,**/Outbox/**/*.cs,**/Localization/**/*.cs,**/Hateoas/**/*.cs,**/DependencyInjection/**/*.cs,**/Providers/**/*.cs,**/UtcDateTimeProvider.cs,**/*.g.cs false true false diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 00000000..7806fb92 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-stryker": { + "version": "4.14.2", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/Analysis/Analysis.Application.UnitTests/Domain/AnalysisBreakdownVoTests.cs b/src/Analysis/Analysis.Application.UnitTests/Domain/AnalysisBreakdownVoTests.cs new file mode 100644 index 00000000..f5baad37 --- /dev/null +++ b/src/Analysis/Analysis.Application.UnitTests/Domain/AnalysisBreakdownVoTests.cs @@ -0,0 +1,240 @@ +namespace Analysis.Application.UnitTests.Domain; + +public sealed class AnalysisBreakdownVoTests +{ + private static TargetBreakdown Target() => + TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, + totalAmount: 1000, overhealing: 0, hitCount: 10, critCount: 3, + minAmount: 50, maxAmount: 200).Value!; + + [Fact] + public void Should_CreateTargetBreakdown_When_Valid() + { + var breakdown = Target(); + breakdown.TargetName.Should().Be("Boss"); + breakdown.AverageAmount.Should().Be(100d); + } + + [Fact] + public void Should_DefaultName_When_TargetNameNull() + { + TargetBreakdown.Create("guid-1", null!, BreakdownMetric.DamageDone, 0, 0, 0, 0, 0, 0) + .Value!.TargetName.Should().BeEmpty(); + } + + [Fact] + public void Should_ReturnZeroAverage_When_TargetHitCountZero() + { + TargetBreakdown.Create("guid-1", "B", BreakdownMetric.DamageDone, 100, 0, 0, 0, 0, 0) + .Value!.AverageAmount.Should().Be(0d); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RejectTargetBreakdown_When_GuidBlank(string guid) + { + TargetBreakdown.Create(guid, "B", BreakdownMetric.DamageDone, 0, 0, 0, 0, 0, 0) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectTargetBreakdown_When_MetricUndefined() + { + TargetBreakdown.Create("g", "B", (BreakdownMetric)99, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + } + + [Theory] + [InlineData(-1, 0, 0, 0, 0, 0)] + [InlineData(0, -1, 0, 0, 0, 0)] + [InlineData(0, 0, -1, 0, 0, 0)] + [InlineData(0, 0, 0, -1, 0, 0)] + [InlineData(0, 0, 0, 0, -1, 0)] + [InlineData(0, 0, 0, 0, 0, -1)] + public void Should_RejectTargetBreakdown_When_AnyFieldNegative( + long total, long over, int hit, int crit, long min, long max) + { + TargetBreakdown.Create("g", "B", BreakdownMetric.DamageDone, total, over, hit, crit, min, max) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectTargetBreakdown_When_CritExceedsHitOrMaxBelowMin() + { + TargetBreakdown.Create("g", "B", BreakdownMetric.DamageDone, 0, 0, 2, 3, 0, 0).IsFailure.Should().BeTrue(); + TargetBreakdown.Create("g", "B", BreakdownMetric.DamageDone, 0, 0, 5, 1, 100, 50).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_TargetBreakdownEquality() + { + Target().Should().Be(Target()); + Target().Should().NotBe(TargetBreakdown.Create("guid-2", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Other", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.HealingDone, 1000, 0, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 999, 0, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 1, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 11, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 4, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 60, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 201).Value!); + } + + private static SpellBreakdown Spell() => + SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!; + + [Fact] + public void Should_CreateSpellBreakdown_When_Valid() + { + Spell().AverageAmount.Should().Be(100d); + } + + [Fact] + public void Should_ReturnZeroAverage_When_SpellHitCountZero() + { + SpellBreakdown.Create(1, "K", BreakdownMetric.DamageDone, 100, 0, 0, 0, 0, 0).Value!.AverageAmount.Should().Be(0d); + } + + [Fact] + public void Should_RejectSpellBreakdown_When_MetricUndefinedOrInvariantsBroken() + { + SpellBreakdown.Create(1, "K", (BreakdownMetric)99, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + SpellBreakdown.Create(1, "K", BreakdownMetric.DamageDone, -1, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + SpellBreakdown.Create(1, "K", BreakdownMetric.DamageDone, 0, 0, 2, 3, 0, 0).IsFailure.Should().BeTrue(); + SpellBreakdown.Create(1, "K", BreakdownMetric.DamageDone, 0, 0, 5, 1, 100, 50).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_SpellBreakdownEquality() + { + Spell().Should().Be(Spell()); + Spell().Should().NotBe(SpellBreakdown.Create(999, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Pummel", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.HealingDone, 1000, 0, 10, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1, 0, 10, 3, 50, 200).Value!); + } + + [Theory] + [InlineData(-1, 0, 0)] + [InlineData(0, -1, 0)] + [InlineData(0, 0, -1)] + public void Should_RejectNegative_When_PatchVersionFrom(int major, int minor, int build) + { + ((Action)(() => PatchVersion.From(major, minor, build))).Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("11.0")] + [InlineData("11.0.5.6")] + [InlineData("11.x.5")] + public void Should_RejectMalformed_When_PatchVersionParse(string raw) + { + ((Action)(() => PatchVersion.Parse(raw))).Should().Throw(); + } + + [Fact] + public void Should_RoundTripAndCompareByComponents_When_PatchVersion() + { + PatchVersion.Parse("11.0.5").ToString().Should().Be("11.0.5"); + PatchVersion.From(11, 0, 5).Should().Be(PatchVersion.From(11, 0, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(12, 0, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 1, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 0, 6)); + } + + private static PlayerStat Stat() => + PlayerStat.Create("Thrall", "Stormrage", WowSpec.RestorationShaman, + totalDamage: 1000, totalHealing: 2000, totalDamageTaken: 500, + interrupts: 3, deaths: 1, activeTimeMs: 30_000, castCount: 100).Value!; + + [Fact] + public void Should_CreatePlayerStat_When_Valid() + { + var stat = Stat(); + stat.Name.Should().Be("Thrall"); + stat.DamagePerSecond(1000).Should().Be(1000d); + stat.HealingPerSecond(1000).Should().Be(2000d); + } + + [Fact] + public void Should_DefaultToEmptyBreakdowns_When_NoneProvided() + { + var stat = Stat(); + stat.SpellBreakdowns.Should().NotBeNull().And.BeEmpty(); + stat.TargetBreakdowns.Should().NotBeNull().And.BeEmpty(); + } + + [Theory] + [InlineData("", "Stormrage")] + [InlineData("Thrall", "")] + public void Should_RejectPlayerStat_When_NameOrRealmBlank(string name, string realm) + { + PlayerStat.Create(name, realm, WowSpec.RestorationShaman, 0, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectPlayerStat_When_SpecUndefinedOrNegativeMetric() + { + PlayerStat.Create("T", "R", (WowSpec)9999, 0, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + PlayerStat.Create("T", "R", WowSpec.RestorationShaman, -1, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_ReturnZeroRates_When_DurationNonPositive() + { + Stat().DamagePerSecond(0).Should().Be(0d); + Stat().HealingPerSecond(-1).Should().Be(0d); + Stat().ActiveTimePercent(0).Should().Be(0d); + } + + [Fact] + public void Should_ClampActiveTimePercent_When_ActiveExceedsDuration() + { + Stat().ActiveTimePercent(10_000).Should().Be(100d); + Stat().ActiveTimePercent(60_000).Should().BeApproximately(50d, 1e-9); + } + + [Fact] + public void Should_KeepProvidedBreakdowns_When_PlayerStatCreated() + { + var stat = PlayerStat.Create("Thrall", "Stormrage", WowSpec.RestorationShaman, + 1000, 2000, 500, 3, 1, 30_000, 100, + spellBreakdowns: [Spell()], + targetBreakdowns: [Target()]).Value!; + + stat.SpellBreakdowns.Should().ContainSingle(); + stat.TargetBreakdowns.Should().ContainSingle(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_PlayerStatEquality() + { + PlayerStat S(string name = "Thrall", string realm = "Stormrage", + WowSpec spec = WowSpec.RestorationShaman, long dmg = 1000, long heal = 2000, + long taken = 500, int interrupts = 3, int deaths = 1, long active = 30_000, int casts = 100) + => PlayerStat.Create(name, realm, spec, dmg, heal, taken, interrupts, deaths, active, casts).Value!; + + S().Should().Be(S()); + S().Should().NotBe(S(name: "Jaina")); + S().Should().NotBe(S(realm: "Hyjal")); + S().Should().NotBe(S(spec: WowSpec.FrostMage)); + S().Should().NotBe(S(dmg: 999)); + S().Should().NotBe(S(heal: 1999)); + S().Should().NotBe(S(taken: 499)); + S().Should().NotBe(S(interrupts: 2)); + S().Should().NotBe(S(deaths: 0)); + S().Should().NotBe(S(active: 29_000)); + S().Should().NotBe(S(casts: 99)); + } + + [Fact] + public void Should_DistinguishByRemainingComponents_When_SpellBreakdownEquality() + { + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 1, 10, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 11, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 4, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 60, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 201).Value!); + } +} diff --git a/src/Analysis/Analysis.Application.UnitTests/Domain/RunAnalysisTests.cs b/src/Analysis/Analysis.Application.UnitTests/Domain/RunAnalysisTests.cs new file mode 100644 index 00000000..23cd0d49 --- /dev/null +++ b/src/Analysis/Analysis.Application.UnitTests/Domain/RunAnalysisTests.cs @@ -0,0 +1,66 @@ +using Analysis.Domain.ValueObjects; + +namespace Analysis.Application.UnitTests.Domain; + +public sealed class RunAnalysisTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private static PlayerStat PlayerWithBreakdowns() + { + var spell = SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!; + var target = TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!; + return PlayerStat.Create("Thrall", "Stormrage", WowSpec.RestorationShaman, + 1000, 2000, 500, 3, 1, 30_000, 100, + spellBreakdowns: [spell], targetBreakdowns: [target]).Value!; + } + + private static RunAnalysis NewAnalysis(bool timed = true, double? percentile = 95) + => RunAnalysis.Create( + RunAnalysisId.New(), + ingestionUnitId: Guid.NewGuid(), + DungeonSlug.FromRawName("The Stonevault"), + KeyLevel.From(18).Value!, + AffixWeek.From([9, 3, 152]).Value!, + ContentDifficulty.MythicPlus, + durationMs: 1_700_000, + timed: timed, + playerStats: [PlayerWithBreakdowns()], + analyzedAt: _at, + partyDpsPercentile: percentile); + + [Fact] + public void Should_ComputeGradeAndRaiseEvent_When_Created() + { + var analysis = NewAnalysis(); + + analysis.Grade.Score.Should().Be(91); + analysis.Grade.Letter.Should().Be(RunGradeLetter.A); + analysis.DomainEvents.OfType().Should().ContainSingle(); + } + + [Fact] + public void Should_RoundtripThroughSnapshot_PreservingGradeAndBreakdowns() + { + var original = NewAnalysis(); + + var rehydrated = RunAnalysis.FromSnapshot(original.ToSnapshot()); + + rehydrated.ToSnapshot().Should().BeEquivalentTo(original.ToSnapshot()); + rehydrated.Grade.Should().Be(original.Grade); + rehydrated.Dungeon.Value.Should().Be("the-stonevault"); + rehydrated.Level.Value.Should().Be(18); + rehydrated.Difficulty.Should().Be(ContentDifficulty.MythicPlus); + + var player = rehydrated.PlayerStats.Should().ContainSingle().Subject; + player.SpellBreakdowns.Should().ContainSingle(); + player.TargetBreakdowns.Should().ContainSingle(); + } + + [Fact] + public void Should_ReflectTimedAndPercentile_InGrade() + { + NewAnalysis(timed: true, percentile: 99).Grade.Score + .Should().BeGreaterThan(NewAnalysis(timed: false, percentile: 1).Grade.Score); + } +} diff --git a/src/Analysis/Analysis.Application.UnitTests/Domain/StopAnalysisVoTests.cs b/src/Analysis/Analysis.Application.UnitTests/Domain/StopAnalysisVoTests.cs new file mode 100644 index 00000000..6bd08bce --- /dev/null +++ b/src/Analysis/Analysis.Application.UnitTests/Domain/StopAnalysisVoTests.cs @@ -0,0 +1,210 @@ +namespace Analysis.Application.UnitTests.Domain; + +public sealed class StopAnalysisVoTests +{ + // ---- RunGrade ---- + + [Fact] + public void Should_GradeHigher_When_TimedHighKeyLowDeaths() + { + var timed = RunGrade.Compute(timed: true, keyLevel: 20, totalDeaths: 0, dpsPercentile: 95); + var untimed = RunGrade.Compute(timed: false, keyLevel: 20, totalDeaths: 10, dpsPercentile: 10); + + timed.Score.Should().BeGreaterThan(untimed.Score); + timed.Letter.Should().Be(RunGradeLetter.S); + } + + [Fact] + public void Should_SanitizeInputs_When_NegativeDeathsOrOutOfRangePercentile() + { + var a = RunGrade.Compute(true, 10, totalDeaths: -5, dpsPercentile: 150); + var b = RunGrade.Compute(true, 10, totalDeaths: 0, dpsPercentile: 100); + + a.Score.Should().Be(b.Score); + } + + [Fact] + public void Should_CapDeathPenalty_When_ManyDeaths() + { + var eight = RunGrade.Compute(true, 10, totalDeaths: 8, dpsPercentile: null); + var twenty = RunGrade.Compute(true, 10, totalDeaths: 20, dpsPercentile: null); + + eight.Score.Should().Be(twenty.Score); + } + + [Theory] + [InlineData(RunGradeLetter.S, 95)] + [InlineData(RunGradeLetter.A, 85)] + [InlineData(RunGradeLetter.F, 10)] + public void Should_ReconstructGrade_When_ScoreWithinLetterRange(RunGradeLetter letter, int score) + { + RunGrade.From(letter, score).Letter.Should().Be(letter); + } + + [Fact] + public void Should_Reject_When_ScoreOutsideLetterRange() + { + ((Action)(() => RunGrade.From(RunGradeLetter.S, 50))).Should().Throw(); + } + + [Theory] + [InlineData(RunGradeLetter.S, 92)] + [InlineData(RunGradeLetter.S, 100)] + [InlineData(RunGradeLetter.A, 80)] + [InlineData(RunGradeLetter.A, 91)] + [InlineData(RunGradeLetter.F, 0)] + [InlineData(RunGradeLetter.F, 39)] + public void Should_AcceptScore_AtExactLetterBoundaries(RunGradeLetter letter, int score) + { + RunGrade.From(letter, score).Score.Should().Be(score); + } + + [Theory] + [InlineData(RunGradeLetter.S, 91)] + [InlineData(RunGradeLetter.A, 79)] + [InlineData(RunGradeLetter.A, 92)] + [InlineData(RunGradeLetter.F, 40)] + public void Should_RejectScore_JustOutsideLetterBoundaries(RunGradeLetter letter, int score) + { + ((Action)(() => RunGrade.From(letter, score))).Should().Throw(); + } + + [Fact] + public void Should_CompareByLetterAndScore_When_RunGradeEquality() + { + RunGrade.From(RunGradeLetter.A, 85).Should().Be(RunGrade.From(RunGradeLetter.A, 85)); + RunGrade.From(RunGradeLetter.A, 85).Should().NotBe(RunGrade.From(RunGradeLetter.A, 86)); + RunGrade.From(RunGradeLetter.A, 85).Should().NotBe(RunGrade.From(RunGradeLetter.B, 75)); + } + + // ---- ClassifiedCast ---- + + private static ClassifiedCast Cast( + int spellId = 12345, string source = "Boss", long start = 1000, long? finish = 2000, + bool interruptible = true, bool dangerous = true, bool stopped = false, int? mobId = 555) => + ClassifiedCast.Create(spellId, source, start, finish, interruptible, dangerous, stopped, mobId).Value!; + + [Theory] + [InlineData(0, "Boss", 1000L, 555)] + [InlineData(12345, "", 1000L, 555)] + [InlineData(12345, "Boss", -1L, 555)] + [InlineData(12345, "Boss", 1000L, 0)] + public void Should_RejectClassifiedCast_When_FieldsInvalid(int spellId, string source, long start, int mobId) + { + ClassifiedCast.Create(spellId, source, start, finishTimeMs: start + 100, true, true, false, mobId) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectClassifiedCast_When_FinishBeforeStart() + { + ClassifiedCast.Create(1, "Boss", 1000, finishTimeMs: 999, true, true, false).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_FlagCompletion_When_FinishTimePresent() + { + Cast(finish: 2000).IsCompleted.Should().BeTrue(); + Cast(finish: null).IsCompleted.Should().BeFalse(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_ClassifiedCastEquality() + { + Cast().Should().Be(Cast()); + Cast().Should().NotBe(Cast(spellId: 999)); + Cast().Should().NotBe(Cast(source: "Other")); + Cast().Should().NotBe(Cast(mobId: 556)); + Cast().Should().NotBe(Cast(start: 1001)); + Cast().Should().NotBe(Cast(finish: 2001)); + Cast().Should().NotBe(Cast(interruptible: false)); + Cast().Should().NotBe(Cast(dangerous: false)); + Cast().Should().NotBe(Cast(stopped: true)); + } + + // ---- StopAction ---- + + private static StopAction Stop( + StopKind kind = StopKind.Kick, string player = "Thrall", int spellId = 47528, + long ts = 1500, StopJusteness just = StopJusteness.Correct, + StopEffectiveness eff = StopEffectiveness.Successful) => + StopAction.Create(kind, player, spellId, ts, Cast(), just, eff).Value!; + + [Fact] + public void Should_RejectStopAction_When_FieldsInvalid() + { + StopAction.Create((StopKind)9, "T", 1, 0, null, StopJusteness.Correct, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, " ", 1, 0, null, StopJusteness.Correct, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, "T", 0, 0, null, StopJusteness.Correct, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, "T", 1, -1, null, StopJusteness.Correct, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, "T", 1, 0, null, (StopJusteness)9, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, "T", 1, 0, null, StopJusteness.Correct, (StopEffectiveness)9).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_AllowNullTargetCast_When_StopActionCreated() + { + StopAction.Create(StopKind.CrowdControl, "Thrall", 47528, 1500, null, StopJusteness.Acceptable, StopEffectiveness.Immune) + .IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_StopActionEquality() + { + Stop().Should().Be(Stop()); + Stop().Should().NotBe(Stop(kind: StopKind.CrowdControl)); + Stop().Should().NotBe(Stop(player: "Jaina")); + Stop().Should().NotBe(Stop(spellId: 47529)); + Stop().Should().NotBe(Stop(ts: 1501)); + Stop().Should().NotBe(Stop(just: StopJusteness.WastedKick)); + Stop().Should().NotBe(Stop(eff: StopEffectiveness.Immune)); + } + + // ---- MissedOpportunity ---- + + [Fact] + public void Should_CreateMissedOpportunity_When_DangerousUnstoppedCastWithReadyPlayers() + { + var result = MissedOpportunity.Create( + Cast(dangerous: true, stopped: false), ["Thrall", " ", "Jaina"]); + + var missed = ThenResultIsSuccess(result); + missed.PlayersWithToolReady.Should().Equal("Thrall", "Jaina"); + } + + [Fact] + public void Should_RejectMissedOpportunity_When_CastNotDangerous() + { + MissedOpportunity.Create(Cast(dangerous: false), ["Thrall"]).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectMissedOpportunity_When_CastWasStopped() + { + MissedOpportunity.Create(Cast(dangerous: true, stopped: true), ["Thrall"]).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectMissedOpportunity_When_NoReadyPlayers() + { + MissedOpportunity.Create(Cast(dangerous: true, stopped: false), [" "]).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectMissedOpportunity_When_PlayerListIsNull() + { + MissedOpportunity.Create(Cast(dangerous: true, stopped: false), null!).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByCastAndPlayers_When_MissedOpportunityEquality() + { + MissedOpportunity Miss(int spellId, params string[] players) => + MissedOpportunity.Create(Cast(spellId: spellId, dangerous: true, stopped: false), players).Value!; + + Miss(99, "Thrall", "Jaina").Should().Be(Miss(99, "Thrall", "Jaina")); + Miss(99, "Thrall").Should().NotBe(Miss(98, "Thrall")); + Miss(99, "Thrall").Should().NotBe(Miss(99, "Jaina")); + Miss(99, "Thrall").Should().NotBe(Miss(99, "Thrall", "Jaina")); + } +} diff --git a/src/Analysis/Analysis.Application.UnitTests/Domain/StopReportTests.cs b/src/Analysis/Analysis.Application.UnitTests/Domain/StopReportTests.cs index a6a5df9f..acea7c9a 100644 --- a/src/Analysis/Analysis.Application.UnitTests/Domain/StopReportTests.cs +++ b/src/Analysis/Analysis.Application.UnitTests/Domain/StopReportTests.cs @@ -87,6 +87,51 @@ public void Counters_Should_AggregateAcrossStopKinds() report.OvercappedStopCount.Should().Be(0); } + [Fact] + public void Counters_Should_DistinguishEachCategory_When_CountsAreAsymmetric() + { + var stops = new[] + { + MakeStop(StopKind.Kick, StopJusteness.Correct, StopEffectiveness.Successful), + MakeStop(StopKind.Kick, StopJusteness.Correct, StopEffectiveness.Successful), + MakeStop(StopKind.Kick, StopJusteness.Correct, StopEffectiveness.Successful), + MakeStop(StopKind.Kick, StopJusteness.WastedKick, StopEffectiveness.LateNoEffect), + MakeStop(StopKind.CrowdControl, StopJusteness.Acceptable, StopEffectiveness.OvercappedStop), + }; + + var report = StopReport.Create(StopReportId.New(), Guid.NewGuid(), + DateTimeOffset.UtcNow, "v1", stops, []).Value!; + + report.TotalStops.Should().Be(5); + report.KickCount.Should().Be(4); + report.CrowdControlCount.Should().Be(1); + report.CorrectStopCount.Should().Be(3); + report.WastedKickCount.Should().Be(1); + report.AcceptableStopCount.Should().Be(1); + report.SuccessfulStopCount.Should().Be(3); + report.LateNoEffectCount.Should().Be(1); + report.ImmuneCount.Should().Be(0); + report.OvercappedStopCount.Should().Be(1); + } + + [Fact] + public void Snapshot_Should_MapStopActionFieldsFaithfully() + { + var stop = StopAction.Create(StopKind.CrowdControl, "Thrall", 6552, 2_400L, null, + StopJusteness.WastedKick, StopEffectiveness.OvercappedStop).Value!; + + var snapshot = StopReport.Create(StopReportId.New(), Guid.NewGuid(), + DateTimeOffset.UtcNow, "v1", [stop], []).Value!.ToSnapshot(); + + var mapped = snapshot.Stops.Should().ContainSingle().Subject; + mapped.Kind.Should().Be((int)StopKind.CrowdControl); + mapped.SourcePlayerName.Should().Be("Thrall"); + mapped.SourceSpellId.Should().Be(6552); + mapped.TimestampMs.Should().Be(2_400L); + mapped.Justeness.Should().Be((int)StopJusteness.WastedKick); + mapped.Effectiveness.Should().Be((int)StopEffectiveness.OvercappedStop); + } + [Fact] public void MissedOpportunityCount_Should_MatchProvidedList() { diff --git a/src/Api/WowPve.Api/Endpoints/Mining.cs b/src/Api/WowPve.Api/Endpoints/Mining.cs index 6a9e0964..30a83ff3 100644 --- a/src/Api/WowPve.Api/Endpoints/Mining.cs +++ b/src/Api/WowPve.Api/Endpoints/Mining.cs @@ -45,7 +45,7 @@ private static async Task Accept( IMediator mediator, Guid candidateId, AcceptBody body, CancellationToken ct) { var result = await mediator.ExecuteAsync( - new AcceptCandidateCommand(candidateId, body.ReviewerId, body.Note, body.MergeIntoExistingUseCase), ct); + new AcceptCandidateCommand(candidateId, body.ReviewerId, body.Note), ct); return result.Match( onSuccess: () => Results.NoContent(), onFailure: err => err.Value switch @@ -75,6 +75,6 @@ private static async Task Reject( }); } - private sealed record AcceptBody(Guid ReviewerId, string? Note, bool MergeIntoExistingUseCase); + private sealed record AcceptBody(Guid ReviewerId, string? Note); private sealed record RejectBody(Guid ReviewerId, string Reason); } diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/CoachingIdTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/CoachingIdTests.cs new file mode 100644 index 00000000..731671ef --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/CoachingIdTests.cs @@ -0,0 +1,56 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class CoachingIdTests +{ + [Fact] + public void Should_RejectEmptyGuid_When_PlayerCoachingProfileIdFromEmpty() + { + var act = () => PlayerCoachingProfileId.From(Guid.Empty); + + act.Should().Throw(); + } + + [Fact] + public void Should_WrapValue_When_PlayerCoachingProfileIdFromGuid() + { + var guid = Guid.NewGuid(); + + PlayerCoachingProfileId.From(guid).Value.Should().Be(guid); + } + + [Fact] + public void Should_BeEqual_When_PlayerCoachingProfileIdsShareValue() + { + var guid = Guid.NewGuid(); + + PlayerCoachingProfileId.From(guid).Should().Be(PlayerCoachingProfileId.From(guid)); + } + + [Fact] + public void Should_GenerateDistinctIds_When_PlayerCoachingProfileIdCreated() + { + PlayerCoachingProfileId.Create().Should().NotBe(PlayerCoachingProfileId.Create()); + } + + [Fact] + public void Should_RejectEmptyGuid_When_UserWeeklyProgressionIdFromEmpty() + { + var act = () => UserWeeklyProgressionId.From(Guid.Empty); + + act.Should().Throw(); + } + + [Fact] + public void Should_BeEqual_When_UserWeeklyProgressionIdsShareValue() + { + var guid = Guid.NewGuid(); + + UserWeeklyProgressionId.From(guid).Should().Be(UserWeeklyProgressionId.From(guid)); + } + + [Fact] + public void Should_GenerateDistinctIds_When_UserWeeklyProgressionIdCreated() + { + UserWeeklyProgressionId.Create().Should().NotBe(UserWeeklyProgressionId.Create()); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/IsoWeekTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/IsoWeekTests.cs index 772ec94e..38932b7e 100644 --- a/src/Coaching/Coaching.Application.UnitTests/DomainTests/IsoWeekTests.cs +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/IsoWeekTests.cs @@ -42,6 +42,87 @@ public void Should_RejectOutOfRangeWeek_When_WeekIsZero() act.Should().Throw(); } + [Fact] + public void Should_RejectOutOfRangeWeek_When_WeekAbove53() + { + var act = () => IsoWeek.From(2026, 54); + + act.Should().Throw(); + } + + [Theory] + [InlineData(1899)] + [InlineData(10000)] + public void Should_RejectOutOfRangeYear_When_YearOutsideSupportedRange(int year) + { + var act = () => IsoWeek.From(year, 10); + + act.Should().Throw(); + } + + [Fact] + public void Should_AcceptBoundaryValues_When_YearAndWeekAtLimits() + { + IsoWeek.From(1900, 1).Should().NotBeNull(); + IsoWeek.From(9999, 53).Should().NotBeNull(); + } + + [Fact] + public void Should_RollIntoNextWeek_When_NextIsCalled() + { + var week = IsoWeek.From(2026, 20); + + var next = week.Next(); + + next.Year.Should().Be(2026); + next.Week.Should().Be(21); + } + + [Fact] + public void Should_RollAcrossYearBoundary_When_NextIsCalledOnLastWeek() + { + var last = IsoWeek.FromDate(new DateOnly(2026, 12, 31)); + + var next = last.Next(); + + next.Year.Should().Be(2027); + } + + [Fact] + public void Should_MapToContainingWeek_When_BuiltFromUtcInstant() + { + var week = IsoWeek.FromUtc(new DateTime(2026, 5, 21, 14, 30, 0, DateTimeKind.Utc)); + + week.Should().Be(IsoWeek.From(2026, 21)); + } + + [Fact] + public void Should_SpanMondayToSunday_When_StartAndEndRequested() + { + var week = IsoWeek.From(2026, 21); + + week.StartDate().Should().Be(new DateOnly(2026, 5, 18)); + week.EndDate().Should().Be(new DateOnly(2026, 5, 24)); + } + + [Fact] + public void Should_RankAfterNull_When_ComparedToNull() + { + IsoWeek.From(2026, 21).CompareTo(null).Should().BeGreaterThan(0); + } + + [Fact] + public void Should_NotBeEqual_When_WeekDiffers() + { + IsoWeek.From(2026, 21).Should().NotBe(IsoWeek.From(2026, 22)); + } + + [Fact] + public void Should_NotBeEqual_When_YearDiffers() + { + IsoWeek.From(2026, 21).Should().NotBe(IsoWeek.From(2025, 21)); + } + [Fact] public void Should_BeEqual_When_SameYearAndWeek() { diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/LeverImpactEstimateTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/LeverImpactEstimateTests.cs new file mode 100644 index 00000000..78b62e8d --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/LeverImpactEstimateTests.cs @@ -0,0 +1,44 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class LeverImpactEstimateTests +{ + [Fact] + public void Should_ExposeBothDimensions_When_Constructed() + { + var estimate = new LeverImpactEstimate(12.5, 2.0); + + estimate.EstimatedDpsGainPct.Should().Be(12.5); + estimate.EstimatedDeathsAvoided.Should().Be(2.0); + } + + [Theory] + [InlineData(double.NaN, 1.0)] + [InlineData(double.PositiveInfinity, 1.0)] + [InlineData(1.0, double.NaN)] + [InlineData(1.0, double.NegativeInfinity)] + public void Should_RefuseConstruction_When_AnyDimensionIsNotFinite(double dps, double deaths) + { + var act = () => new LeverImpactEstimate(dps, deaths); + + act.Should().Throw(); + } + + [Theory] + [InlineData(-0.1, 1.0)] + [InlineData(1.0, -0.1)] + public void Should_RefuseConstruction_When_AnyDimensionIsNegative(double dps, double deaths) + { + var act = () => new LeverImpactEstimate(dps, deaths); + + act.Should().Throw(); + } + + [Fact] + public void Should_AllowZero_When_LeverHasNoEstimatedGain() + { + var estimate = new LeverImpactEstimate(0, 0); + + estimate.EstimatedDpsGainPct.Should().Be(0); + estimate.EstimatedDeathsAvoided.Should().Be(0); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/StopsMetricsTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/StopsMetricsTests.cs new file mode 100644 index 00000000..70db0fa1 --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/StopsMetricsTests.cs @@ -0,0 +1,77 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class StopsMetricsTests +{ + private static StopsMetrics WithOneNegative(int index) + { + var v = new int[12]; + for (var i = 0; i < v.Length; i++) + v[i] = 1; + v[index] = -1; + return new StopsMetrics( + v[0], v[1], v[2], v[3], v[4], v[5], + v[6], v[7], v[8], v[9], v[10], v[11]); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(11)] + public void Should_RefuseConstruction_When_AnyCountIsNegative(int negativeField) + { + var act = () => WithOneNegative(negativeField); + + act.Should().Throw(); + } + + [Fact] + public void Should_ExposeEveryCount_When_Constructed() + { + var metrics = new StopsMetrics( + totalStops: 1, kickCount: 2, crowdControlCount: 3, + correctStopCount: 4, acceptableStopCount: 5, wastedKickCount: 6, + successfulStopCount: 7, lateNoEffectCount: 8, immuneCount: 9, + overcappedStopCount: 10, missedOpportunityCount: 11, runCount: 12); + + metrics.TotalStops.Should().Be(1); + metrics.KickCount.Should().Be(2); + metrics.CrowdControlCount.Should().Be(3); + metrics.CorrectStopCount.Should().Be(4); + metrics.AcceptableStopCount.Should().Be(5); + metrics.WastedKickCount.Should().Be(6); + metrics.SuccessfulStopCount.Should().Be(7); + metrics.LateNoEffectCount.Should().Be(8); + metrics.ImmuneCount.Should().Be(9); + metrics.OvercappedStopCount.Should().Be(10); + metrics.MissedOpportunityCount.Should().Be(11); + metrics.RunCount.Should().Be(12); + } + + [Fact] + public void Should_BeAllZero_When_Empty() + { + var empty = StopsMetrics.Empty; + + empty.TotalStops.Should().Be(0); + empty.KickCount.Should().Be(0); + empty.CrowdControlCount.Should().Be(0); + empty.CorrectStopCount.Should().Be(0); + empty.AcceptableStopCount.Should().Be(0); + empty.WastedKickCount.Should().Be(0); + empty.SuccessfulStopCount.Should().Be(0); + empty.LateNoEffectCount.Should().Be(0); + empty.ImmuneCount.Should().Be(0); + empty.OvercappedStopCount.Should().Be(0); + empty.MissedOpportunityCount.Should().Be(0); + empty.RunCount.Should().Be(0); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/UseCaseKeyTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/UseCaseKeyTests.cs new file mode 100644 index 00000000..5e86803e --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/UseCaseKeyTests.cs @@ -0,0 +1,63 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class UseCaseKeyTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RefuseConstruction_When_Blank(string value) + { + var act = () => UseCaseKey.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_RefuseConstruction_When_LongerThanMaxLength() + { + var tooLong = new string('a', UseCaseKey.MaxLength + 1); + + var act = () => UseCaseKey.From(tooLong); + + act.Should().Throw(); + } + + [Theory] + [InlineData("Kick Uptime")] + [InlineData("kick_uptime")] + [InlineData("-kick")] + [InlineData("kick--uptime")] + [InlineData("kické")] + public void Should_RefuseConstruction_When_NotKebabCaseAscii(string value) + { + var act = () => UseCaseKey.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_NormalizeToLowerTrimmed_When_GivenPaddedMixedCase() + { + UseCaseKey.From(" Kick-Uptime ").Value.Should().Be("kick-uptime"); + } + + [Fact] + public void Should_AcceptMaxLength_When_ExactlyAtLimit() + { + var atLimit = new string('a', UseCaseKey.MaxLength); + + UseCaseKey.From(atLimit).Value.Should().Be(atLimit); + } + + [Fact] + public void Should_BeEqual_When_SameNormalizedValue() + { + UseCaseKey.From("kick-uptime").Should().Be(UseCaseKey.From("KICK-UPTIME")); + } + + [Fact] + public void Should_NotBeEqual_When_DifferentValue() + { + UseCaseKey.From("kick-uptime").Should().NotBe(UseCaseKey.From("dispel-debuff")); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/WowCharacterRefTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/WowCharacterRefTests.cs new file mode 100644 index 00000000..d34530ad --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/WowCharacterRefTests.cs @@ -0,0 +1,76 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class WowCharacterRefTests +{ + [Theory] + [InlineData("", "stormrage", "thrall")] + [InlineData(" ", "stormrage", "thrall")] + [InlineData("eu", "", "thrall")] + [InlineData("eu", "stormrage", "")] + public void Should_RefuseConstruction_When_AnyPartIsBlank(string region, string realm, string name) + { + var act = () => WowCharacterRef.From(region, realm, name); + + act.Should().Throw(); + } + + [Theory] + [InlineData("xx")] + [InlineData("na")] + [InlineData("europe")] + public void Should_RefuseConstruction_When_RegionIsNotABlizzardRegion(string region) + { + var act = () => WowCharacterRef.From(region, "stormrage", "thrall"); + + act.Should().Throw(); + } + + [Theory] + [InlineData("eu")] + [InlineData("us")] + [InlineData("kr")] + [InlineData("tw")] + [InlineData("cn")] + public void Should_Accept_When_RegionIsAKnownBlizzardRegion(string region) + { + WowCharacterRef.From(region, "stormrage", "thrall").Region.Should().Be(region); + } + + [Fact] + public void Should_NormalizeRegionAndRealm_When_GivenMixedCaseWithWhitespace() + { + var character = WowCharacterRef.From(" EU ", " Stormrage ", " Thrall "); + + character.Region.Should().Be("eu"); + character.RealmSlug.Should().Be("stormrage"); + character.Name.Should().Be("Thrall"); + } + + [Fact] + public void Should_BeDistinct_When_OnlyRegionDiffers() + { + WowCharacterRef.From("eu", "stormrage", "thrall") + .Should().NotBe(WowCharacterRef.From("us", "stormrage", "thrall")); + } + + [Fact] + public void Should_BeDistinct_When_OnlyRealmDiffers() + { + WowCharacterRef.From("eu", "stormrage", "thrall") + .Should().NotBe(WowCharacterRef.From("eu", "hyjal", "thrall")); + } + + [Fact] + public void Should_BeDistinct_When_OnlyNameDiffers() + { + WowCharacterRef.From("eu", "stormrage", "thrall") + .Should().NotBe(WowCharacterRef.From("eu", "stormrage", "jaina")); + } + + [Fact] + public void Should_BeEqual_When_RegionRealmAndNameMatch() + { + WowCharacterRef.From("eu", "stormrage", "thrall") + .Should().Be(WowCharacterRef.From("eu", "stormrage", "thrall")); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/Profiles/Commands/IngestRunOutcomeUseCaseTests.cs b/src/Coaching/Coaching.Application.UnitTests/Profiles/Commands/IngestRunOutcomeUseCaseTests.cs index 1ef394a9..e690a80e 100644 --- a/src/Coaching/Coaching.Application.UnitTests/Profiles/Commands/IngestRunOutcomeUseCaseTests.cs +++ b/src/Coaching/Coaching.Application.UnitTests/Profiles/Commands/IngestRunOutcomeUseCaseTests.cs @@ -85,6 +85,42 @@ public async Task Should_FailWithTooManyLevers_When_MoreThanThreeProvided() ThenResultIsFailureWithError(result, CoachingErrors.Profile.TooManyLevers); } + [Fact] + public async Task Should_FailWithNoLevers_When_LeverListIsEmpty() + { + var result = await _useCase.HandleAsync(new IngestRunOutcomeCommand( + PlayerCoachingProfileId.From(_profileId), Guid.NewGuid(), [])); + + ThenResultIsFailureWithError(result, CoachingErrors.Profile.NoLeversProvided); + } + + [Fact] + public async Task Should_FailWithInvalidPriority_When_PrioritySlotIsNotDefined() + { + var result = await _useCase.HandleAsync(new IngestRunOutcomeCommand( + PlayerCoachingProfileId.From(_profileId), Guid.NewGuid(), + [Lever("a", (LeverPriority)99)])); + + ThenResultIsFailureWithError(result, CoachingErrors.Profile.InvalidLeverPriority); + } + + [Fact] + public async Task Should_StoreLeversSortedByPriority_When_ProvidedOutOfOrder() + { + var result = await _useCase.HandleAsync(new IngestRunOutcomeCommand( + PlayerCoachingProfileId.From(_profileId), Guid.NewGuid(), + [ + Lever("third-lever", LeverPriority.Third), + Lever("top-lever", LeverPriority.Top), + Lever("second-lever", LeverPriority.Second), + ])); + + ThenResultIsSuccess(result); + var stored = _repository.All.Single(); + stored.ActiveLevers.Select(l => l.UseCaseKey) + .Should().Equal("top-lever", "second-lever", "third-lever"); + } + [Fact] public async Task Should_FailWithDuplicatePriority_When_SamePrioritySlotUsedTwice() { diff --git a/src/Coaching/Coaching.Application.UnitTests/Progression/Commands/ComputeWeeklySnapshotsUseCaseTests.cs b/src/Coaching/Coaching.Application.UnitTests/Progression/Commands/ComputeWeeklySnapshotsUseCaseTests.cs index a52b35e5..ce66535d 100644 --- a/src/Coaching/Coaching.Application.UnitTests/Progression/Commands/ComputeWeeklySnapshotsUseCaseTests.cs +++ b/src/Coaching/Coaching.Application.UnitTests/Progression/Commands/ComputeWeeklySnapshotsUseCaseTests.cs @@ -57,6 +57,21 @@ public async Task Should_SkipUser_When_SnapshotAlreadyExistsForThatWeek() _repository.All.Should().HaveCount(1); } + [Fact] + public async Task Should_SkipUser_When_MetricsEntryHasNoIdentifiableUser() + { + _metricsSource.Seed(_week, + new UserWeeklyStopMetrics(Guid.Empty, SampleMetrics()), + new UserWeeklyStopMetrics(Guid.NewGuid(), SampleMetrics())); + + var result = await _useCase.HandleAsync(new ComputeWeeklySnapshotsCommand(_week)); + + var summary = ThenResultIsSuccess(result); + summary.Computed.Should().Be(1); + summary.Skipped.Should().Be(1); + _repository.All.Should().HaveCount(1); + } + [Fact] public async Task Should_ReturnZeroSummary_When_SourceHasNoMetricsForWeek() { diff --git a/src/Comparison/Comparison.Application.UnitTests/Cohorts/Commands/RebuildCohortsUseCaseTests.cs b/src/Comparison/Comparison.Application.UnitTests/Cohorts/Commands/RebuildCohortsUseCaseTests.cs index e0b89738..b926f943 100644 --- a/src/Comparison/Comparison.Application.UnitTests/Cohorts/Commands/RebuildCohortsUseCaseTests.cs +++ b/src/Comparison/Comparison.Application.UnitTests/Cohorts/Commands/RebuildCohortsUseCaseTests.cs @@ -122,6 +122,22 @@ public async Task Should_SkipTarget_When_PeriodKeyIsInvalid() view.SkippedEmptySamples.Should().Be(1); } + [Fact] + public async Task Should_SkipTarget_When_TierIsNotDefined() + { + _source.Seed(3009, 5, 640, new RunMetricSample("dps", 100)); + + var result = await _useCase.HandleAsync(new RebuildCohortsCommand( + [ + new CohortRebuildTarget(3009, 5, 640, _periodStart, _periodEnd, Tier: (CohortTier)999), + ])); + + var view = ThenResultIsSuccess(result); + view.BuiltCohorts.Should().Be(0); + view.SkippedEmptySamples.Should().Be(1); + _cohortRepository.All.Should().BeEmpty(); + } + [Fact] public async Task Should_RaiseCohortBuiltEvent_When_CohortPersisted() { diff --git a/src/Comparison/Comparison.Application.UnitTests/Cohorts/DomainTests/ComparisonValueObjectTests.cs b/src/Comparison/Comparison.Application.UnitTests/Cohorts/DomainTests/ComparisonValueObjectTests.cs new file mode 100644 index 00000000..c8e15d5e --- /dev/null +++ b/src/Comparison/Comparison.Application.UnitTests/Cohorts/DomainTests/ComparisonValueObjectTests.cs @@ -0,0 +1,181 @@ +namespace Comparison.Application.UnitTests.Cohorts.DomainTests; + +public sealed class ComparisonValueObjectTests +{ + private static readonly DateTime _start = new(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _end = new(2026, 5, 8, 0, 0, 0, DateTimeKind.Utc); + + private static CohortKey Key( + int encounter = 3009, int difficulty = 5, int ilvlFloor = 600, + DateTime? start = null, DateTime? end = null, string? pull = null) => + CohortKey.From(encounter, difficulty, ilvlFloor, start ?? _start, end ?? _end, pull); + + [Fact] + public void Should_RejectNonPositiveEncounter_When_CohortKeyFrom() + { + ((Action)(() => Key(encounter: 0))).Should().Throw(); + } + + [Fact] + public void Should_RejectNonPositiveDifficulty_When_CohortKeyFrom() + { + ((Action)(() => Key(difficulty: 0))).Should().Throw(); + } + + [Fact] + public void Should_RejectNegativeIlvlFloor_When_CohortKeyFrom() + { + ((Action)(() => Key(ilvlFloor: -1))).Should().Throw(); + } + + [Fact] + public void Should_RejectInvertedPeriod_When_StartNotBeforeEnd() + { + ((Action)(() => Key(start: _end, end: _start))).Should().Throw(); + } + + [Fact] + public void Should_RejectEmptyPullSignature_When_Provided() + { + ((Action)(() => Key(pull: ""))).Should().Throw(); + } + + [Fact] + public void Should_AllowNullPullSignature_When_NotScopedToPullShape() + { + Key(pull: null).PullSignature.Should().BeNull(); + } + + [Fact] + public void Should_AllowZeroIlvlFloor_When_BracketStartsAtZero() + { + Key(ilvlFloor: 0).IlvlBracketFloor.Should().Be(0); + } + + [Fact] + public void Should_RejectEqualPeriodBounds_When_StartEqualsEnd() + { + ((Action)(() => Key(start: _start, end: _start))).Should().Throw(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_CohortKeyEquality() + { + Key().Should().Be(Key()); + Key().Should().NotBe(Key(encounter: 3010)); + Key().Should().NotBe(Key(difficulty: 4)); + Key().Should().NotBe(Key(ilvlFloor: 610)); + Key().Should().NotBe(Key(end: _end.AddDays(1))); + Key().Should().NotBe(Key(start: _start.AddDays(-1))); + Key(pull: "abc").Should().NotBe(Key(pull: "def")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Kicks Rate")] + [InlineData("kicks_rate")] + public void Should_Reject_When_MetricKeyBlankOrNonKebab(string value) + { + ((Action)(() => MetricKey.From(value))).Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_MetricKeyExceedsMax() + { + ((Action)(() => MetricKey.From(new string('a', MetricKey.MaxLength + 1)))) + .Should().Throw(); + } + + [Fact] + public void Should_NormalizeAndCompareByValue_When_MetricKey() + { + MetricKey.From(" Kicks-Rate ").Value.Should().Be("kicks-rate"); + MetricKey.From("kicks-rate").Should().Be(MetricKey.From("KICKS-RATE")); + MetricKey.From("kicks-rate").Should().NotBe(MetricKey.From("deaths-avoidable")); + } + + [Fact] + public void Should_RejectEmptyKey_When_MetricPercentile() + { + ((Action)(() => new MetricPercentile("", 1, 2, 3, 4, 10))).Should().Throw(); + } + + [Fact] + public void Should_AcceptNonDecreasingPercentiles_When_HigherIsBetter() + { + new MetricPercentile("dps", 1, 2, 3, 4, 10).P95.Should().Be(3); + } + + [Fact] + public void Should_AcceptNonIncreasingPercentiles_When_LowerIsBetter() + { + new MetricPercentile("avoidable-damage", 4, 3, 2, 1, 10).P50.Should().Be(4); + } + + [Theory] + [InlineData(2, 2, 3, 4)] + [InlineData(2, 3, 3, 4)] + [InlineData(2, 3, 4, 4)] + [InlineData(5, 5, 5, 5)] + public void Should_AcceptEqualAdjacentPercentiles_When_NonDecreasing(double p50, double p75, double p95, double p99) + { + new MetricPercentile("dps", p50, p75, p95, p99, 10).P50.Should().Be(p50); + } + + [Theory] + [InlineData(4, 4, 3, 2)] + [InlineData(4, 3, 3, 2)] + [InlineData(4, 3, 2, 2)] + public void Should_AcceptEqualAdjacentPercentiles_When_NonIncreasing(double p50, double p75, double p95, double p99) + { + new MetricPercentile("avoidable-damage", p50, p75, p95, p99, 10).P50.Should().Be(p50); + } + + [Theory] + [InlineData(1, 3, 2, 4)] + [InlineData(3, 1, 2, 4)] + [InlineData(1, 2, 4, 3)] + [InlineData(2, 1, 3, 4)] + public void Should_RejectNonMonotonicPercentiles_When_OrderingIsMixed(double p50, double p75, double p95, double p99) + { + ((Action)(() => new MetricPercentile("dps", p50, p75, p95, p99, 10))).Should().Throw(); + } + + [Fact] + public void Should_RejectNegativeSampleSize_When_MetricPercentile() + { + ((Action)(() => new MetricPercentile("dps", 1, 2, 3, 4, -1))).Should().Throw(); + } + + [Fact] + public void Should_ScoreRelativeToSpread_When_DeltaScoreOnHealthyDistribution() + { + var percentile = new MetricPercentile("dps", 100, 150, 200, 250, 10); + + percentile.DeltaScore(200).Should().BeApproximately(1.0, 1e-9); + percentile.DeltaScore(100).Should().Be(0); + percentile.DeltaScore(50).Should().BeApproximately(-0.5, 1e-9); + } + + [Fact] + public void Should_ReturnZero_When_DeltaScoreOnDegenerateDistribution() + { + new MetricPercentile("dps", 100, 100, 100, 100, 10).DeltaScore(999).Should().Be(0); + } + + [Fact] + public void Should_RejectEmptyGuid_When_CohortIdFromEmpty() + { + ((Action)(() => CohortId.From(Guid.Empty))).Should().Throw(); + } + + [Fact] + public void Should_WrapAndCompareByValue_When_CohortId() + { + var guid = Guid.NewGuid(); + CohortId.From(guid).Value.Should().Be(guid); + CohortId.From(guid).Should().Be(CohortId.From(guid)); + CohortId.Create().Should().NotBe(CohortId.Create()); + } +} diff --git a/src/Comparison/Comparison.Application/UseCases/Cohorts/Commands/RebuildCohorts/RebuildCohortsUseCase.cs b/src/Comparison/Comparison.Application/UseCases/Cohorts/Commands/RebuildCohorts/RebuildCohortsUseCase.cs index d356f24a..d6a4b15b 100644 --- a/src/Comparison/Comparison.Application/UseCases/Cohorts/Commands/RebuildCohorts/RebuildCohortsUseCase.cs +++ b/src/Comparison/Comparison.Application/UseCases/Cohorts/Commands/RebuildCohorts/RebuildCohortsUseCase.cs @@ -45,16 +45,9 @@ public async Task> HandleAsync( continue; } - var sampleSize = samples.Select(s => s.MetricKey).Distinct().Count() > 0 - ? samples.GroupBy(s => s.MetricKey).Max(g => g.Count()) - : 0; + var sampleSize = samples.GroupBy(s => s.MetricKey).Max(g => g.Count()); var percentiles = ComputePercentiles(samples, sampleSize); - if (percentiles.Count == 0) - { - skipped++; - continue; - } var keyResult = Guard.TryBuild( () => CohortKey.From( @@ -95,7 +88,6 @@ private static List ComputePercentiles( foreach (var group in byMetric) { var sorted = group.Select(s => s.Value).OrderBy(v => v).ToArray(); - if (sorted.Length == 0) continue; result.Add(new MetricPercentile( MetricKey: group.Key, P50: Percentile(sorted, 0.50), diff --git a/src/Desktop/Wow.Desktop.Core.UnitTests/Logs/EncounterSegmenterMetadataTests.cs b/src/Desktop/Wow.Desktop.Core.UnitTests/Logs/EncounterSegmenterMetadataTests.cs new file mode 100644 index 00000000..456392fa --- /dev/null +++ b/src/Desktop/Wow.Desktop.Core.UnitTests/Logs/EncounterSegmenterMetadataTests.cs @@ -0,0 +1,125 @@ +namespace Wow.Desktop.Core.UnitTests.Logs; + +public sealed class EncounterSegmenterMetadataTests +{ + private readonly EncounterSegmenter _sut = new(); + + [Fact] + public void Should_ParseChallengeMetadata_FromStartAndEnd() + { + var emitted = _sut.Feed([ + "8/14 21:00:00.000 CHALLENGE_MODE_START,\"Ara-Kara, City of Echoes\",503,2902,12,[9,3,152]", + "8/14 21:32:11.500 CHALLENGE_MODE_END,503,1,12,1931456" + ]); + + var meta = emitted.Should().ContainSingle().Subject.Metadata!; + meta.MapId.Should().Be(503); + meta.InstanceId.Should().Be(2902); + meta.KeyLevel.Should().Be(12); + meta.AffixIds.Should().Equal(9, 3, 152); + meta.Success.Should().BeTrue(); + meta.DurationMs.Should().Be(1931456); + } + + [Fact] + public void Should_MarkDungeonDepleted_When_ChallengeEndSuccessIsZero() + { + var emitted = _sut.Feed([ + "8/14 21:00:00.000 CHALLENGE_MODE_START,\"Ara-Kara\",503,2902,12,[9]", + "8/14 21:40:00.000 CHALLENGE_MODE_END,503,0,12,2400000" + ]); + + emitted.Should().ContainSingle().Subject.Metadata!.Success.Should().BeFalse(); + } + + [Fact] + public void Should_ParseEncounterMetadata_FromStartAndEnd() + { + var emitted = _sut.Feed([ + "8/14 22:00:00.000 ENCOUNTER_START,2902,\"Ulgrax\",16,20,2657", + "8/14 22:05:00.000 ENCOUNTER_END,2902,\"Ulgrax\",16,20,1,300000" + ]); + + var meta = emitted.Should().ContainSingle().Subject.Metadata!; + meta.EncounterId.Should().Be(2902); + meta.DifficultyId.Should().Be(16); + meta.GroupSize.Should().Be(20); + meta.InstanceId.Should().Be(2657); + meta.Success.Should().BeTrue(); + meta.DurationMs.Should().Be(300000); + meta.AttemptNumber.Should().Be(1); + } + + [Fact] + public void Should_IncrementAttempt_When_SameEncounterPulledAgain_AndResetForDifferent() + { + var emitted = _sut.Feed([ + "1 ENCOUNTER_START,2902,\"Boss\",16,20,1", + "2 ENCOUNTER_END,2902,\"Boss\",16,20,0,60000", + "3 ENCOUNTER_START,2902,\"Boss\",16,20,1", + "4 ENCOUNTER_END,2902,\"Boss\",16,20,0,60000", + "5 ENCOUNTER_START,3001,\"Other\",16,20,1", + "6 ENCOUNTER_END,3001,\"Other\",16,20,1,60000" + ]); + + emitted.Should().HaveCount(3); + emitted[0].Metadata!.AttemptNumber.Should().Be(1); + emitted[1].Metadata!.AttemptNumber.Should().Be(2); + emitted[2].Metadata!.AttemptNumber.Should().Be(1); + } + + [Fact] + public void Should_AbandonOpenSegment_When_ZoneChanges() + { + var emitted = _sut.FeedWithAbandons([ + "1 ENCOUNTER_START,2902,\"Boss\",16,20,1", + "2 SPELL_DAMAGE,Player-1,\"Toon\",1,2,3,4,5,6", + "3 ZONE_CHANGE,2657,\"Nerub-ar Palace\",0" + ], out var abandoned); + + emitted.Should().BeEmpty(); + abandoned.Should().ContainSingle().Subject.Kind.Should().Be(EncounterSegmentKind.RaidEncounter); + _sut.HasOpenSegment.Should().BeFalse(); + } + + [Fact] + public void Should_ReturnNullMetadataFields_When_PayloadMalformed() + { + var emitted = _sut.Feed([ + "1 ENCOUNTER_START,notanumber,\"Boss\",x,y,z", + "2 ENCOUNTER_END,notanumber,\"Boss\",x,y,nope" + ]); + + var meta = emitted.Should().ContainSingle().Subject.Metadata!; + meta.EncounterId.Should().BeNull(); + meta.DifficultyId.Should().BeNull(); + meta.Success.Should().BeFalse(); + } + + [Fact] + public void Should_IgnoreEncounterEnd_When_NoMatchingStart() + { + _sut.Feed(["1 ENCOUNTER_END,2902,\"Boss\",16,20,1,300000"]) + .Should().BeEmpty(); + _sut.HasOpenSegment.Should().BeFalse(); + } + + [Fact] + public void Should_IgnoreLinesWithoutTimestampSeparator() + { + _sut.Feed(["ENCOUNTER_START,2902,\"Boss\",16,20,1"]).Should().BeEmpty(); + _sut.HasOpenSegment.Should().BeFalse(); + } + + [Fact] + public void Should_NotCloseRaidEncounter_When_ChallengeEndSeen() + { + var emitted = _sut.Feed([ + "1 ENCOUNTER_START,2902,\"Boss\",16,20,1", + "2 CHALLENGE_MODE_END,503,1,12,1931456" + ]); + + emitted.Should().BeEmpty(); + _sut.HasOpenSegment.Should().BeTrue(); + } +} diff --git a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AddPhaseUseCaseTests.cs b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AddPhaseUseCaseTests.cs index 12d8d390..cf3f9e76 100644 --- a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AddPhaseUseCaseTests.cs +++ b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AddPhaseUseCaseTests.cs @@ -42,6 +42,36 @@ public async Task Should_FailWithNotFound_When_EncounterMissing() ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.NotFound); } + [Fact] + public async Task Should_FailWithInvalidOrdinal_When_OrdinalBelowOne() + { + var result = await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 0, "Pull")); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.InvalidPhaseOrdinal); + } + + [Fact] + public async Task Should_KeepPhasesOrderedByOrdinal_When_AddedOutOfOrder() + { + await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 3, "Phase 3")); + await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 1, "Phase 1")); + await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 2, "Phase 2")); + + var stored = await _repository.GetByIdAsync(EncounterId.From(_existingId)); + stored!.Phases.Select(p => p.Ordinal).Should().Equal(1, 2, 3); + } + + [Fact] + public async Task Should_RaisePhaseAddedEvent_When_PhaseAdded() + { + await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 2, "Intermission")); + + var evt = _unitOfWork.CapturedEvents.OfType() + .Should().ContainSingle().Subject; + evt.Ordinal.Should().Be(2); + evt.Name.Should().Be("Intermission"); + } + [Fact] public async Task Should_TrackEncounterInOutbox_When_PhaseAdded() { diff --git a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AttachUseCaseUseCaseTests.cs b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AttachUseCaseUseCaseTests.cs index 57aa02e5..3a9bc636 100644 --- a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AttachUseCaseUseCaseTests.cs +++ b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AttachUseCaseUseCaseTests.cs @@ -101,4 +101,37 @@ await _useCase.HandleAsync(new AttachUseCaseCommand( _unitOfWork.TrackedAggregates.OfType() .Should().ContainSingle(); } + + [Fact] + public async Task Should_FailWithInvalidKind_When_KindNotDefined() + { + var result = await _useCase.HandleAsync(new AttachUseCaseCommand( + EncounterId.From(_existingId), + Key: UseCaseKey.From("kick-something"), + Kind: (UseCaseKind)999, + PhaseOrdinal: 1, + TargetSpellBlizzardIds: [], + ExpectedResponseBlizzardIds: [], + Priority: 1)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.InvalidUseCaseKind); + } + + [Fact] + public async Task Should_RaiseUseCaseAttachedEvent_When_Attached() + { + await _useCase.HandleAsync(new AttachUseCaseCommand( + EncounterId.From(_existingId), + Key: UseCaseKey.From("kick-necrotic-bolt"), + Kind: UseCaseKind.Interrupt, + PhaseOrdinal: 1, + TargetSpellBlizzardIds: [444444], + ExpectedResponseBlizzardIds: [47528], + Priority: 7)); + + var evt = _unitOfWork.CapturedEvents.OfType() + .Should().ContainSingle().Subject; + evt.Key.Should().Be("kick-necrotic-bolt"); + evt.Priority.Should().Be(7); + } } diff --git a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterAggregateTests.cs b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterAggregateTests.cs new file mode 100644 index 00000000..4dfbce9f --- /dev/null +++ b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterAggregateTests.cs @@ -0,0 +1,111 @@ +using EncounterKnowledge.Domain.Aggregates.Encounters; + +namespace EncounterKnowledge.Application.UnitTests.Encounters.DomainTests; + +public sealed class EncounterAggregateTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private static Encounter NewEncounter(Provenance? provenance = null) => + Encounter.Create( + BlizzardEncounterId.From(3009), + PatchVersion.Parse("12.0.0"), + ContentDifficulty.Mythic, + EncounterName.From("Manaforge Omega"), + _at, + provenance).Value!; + + [Fact] + public void Should_DefaultToManualProvenance_When_NoneProvided() + { + NewEncounter().Provenance.Source.Should().Be(ProvenanceSource.Manual); + } + + [Fact] + public void Should_KeepProvidedProvenance_When_ExplicitlySupplied() + { + NewEncounter(Provenance.Blizzard(_at)).Provenance.Source.Should().Be(ProvenanceSource.BlizzardApi); + } + + [Fact] + public void Should_FailWithInvalidDifficulty_When_DifficultyNotDefined() + { + var result = Encounter.Create( + BlizzardEncounterId.From(3009), + PatchVersion.Parse("12.0.0"), + (ContentDifficulty)999, + EncounterName.From("Boss"), + _at); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.InvalidDifficulty); + } + + private static Encounter WithAttachedUseCase(out UseCaseKey key, Provenance? provenance = null) + { + var encounter = NewEncounter(); + encounter.AddPhase(1, "Phase 1"); + key = UseCaseKey.From("kick-necrotic-bolt"); + encounter.AttachUseCase( + key, UseCaseKind.Interrupt, 1, [444444], [47528], 10, _at, provenance); + return encounter; + } + + [Fact] + public void Should_FailWithUseCaseNotFound_When_UpdatingUnknownKey() + { + var encounter = WithAttachedUseCase(out _); + + var result = encounter.UpdateUseCase( + UseCaseKey.From("does-not-exist"), UseCaseKind.Interrupt, 1, [], [], 1, + Provenance.Manual(_at)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.UseCaseNotFound); + } + + [Fact] + public void Should_FailWithInvalidKind_When_UpdatingWithUndefinedKind() + { + var encounter = WithAttachedUseCase(out var key); + + var result = encounter.UpdateUseCase( + key, (UseCaseKind)999, 1, [], [], 1, Provenance.Manual(_at)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.InvalidUseCaseKind); + } + + [Fact] + public void Should_FailWithUnknownPhase_When_UpdatingToUndeclaredPhase() + { + var encounter = WithAttachedUseCase(out var key); + + var result = encounter.UpdateUseCase( + key, UseCaseKind.Interrupt, 99, [], [], 1, Provenance.Manual(_at)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.UnknownPhase); + } + + [Fact] + public void Should_RefuseOverwrite_When_ManualUseCaseUpdatedByNonManualSource() + { + var encounter = WithAttachedUseCase(out var key, Provenance.Manual(_at)); + + var result = encounter.UpdateUseCase( + key, UseCaseKind.Defensive, 1, [], [], 2, Provenance.Blizzard(_at)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.ManualOverrideProtected); + } + + [Fact] + public void Should_ReplaceUseCaseInPlace_When_UpdateIsAllowed() + { + var encounter = WithAttachedUseCase(out var key, Provenance.Blizzard(_at)); + + var result = encounter.UpdateUseCase( + key, UseCaseKind.Defensive, 1, [12345], [67890], 3, Provenance.Blizzard(_at)); + + ThenResultIsSuccess(result); + var useCase = encounter.UseCases.Should().ContainSingle().Subject; + useCase.Kind.Should().Be(UseCaseKind.Defensive); + useCase.Priority.Should().Be(3); + } +} diff --git a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterValueObjectTests.cs b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterValueObjectTests.cs new file mode 100644 index 00000000..4758a374 --- /dev/null +++ b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterValueObjectTests.cs @@ -0,0 +1,157 @@ +namespace EncounterKnowledge.Application.UnitTests.Encounters.DomainTests; + +public sealed class EncounterValueObjectTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Should_RejectNonPositive_When_BlizzardEncounterIdFrom(int value) + { + var act = () => BlizzardEncounterId.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_WrapValueAndCompareByValue_When_BlizzardEncounterId() + { + BlizzardEncounterId.From(3009).Value.Should().Be(3009); + BlizzardEncounterId.From(3009).Should().Be(BlizzardEncounterId.From(3009)); + BlizzardEncounterId.From(3009).Should().NotBe(BlizzardEncounterId.From(3010)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RejectBlank_When_EncounterNameFrom(string value) + { + var act = () => EncounterName.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_EncounterNameExceedsMax() + { + var act = () => EncounterName.From(new string('a', EncounterName.MaxLength + 1)); + + act.Should().Throw(); + } + + [Fact] + public void Should_TrimAndCompareByValue_When_EncounterName() + { + EncounterName.From(" Manaforge Omega ").Value.Should().Be("Manaforge Omega"); + EncounterName.From("Manaforge Omega").Should().Be(EncounterName.From("Manaforge Omega")); + EncounterName.From("Manaforge Omega").Should().NotBe(EncounterName.From("Other Boss")); + } + + [Fact] + public void Should_RejectEmptyGuid_When_EncounterIdFromEmpty() + { + var act = () => EncounterId.From(Guid.Empty); + + act.Should().Throw(); + } + + [Fact] + public void Should_WrapAndCompareByValue_When_EncounterId() + { + var guid = Guid.NewGuid(); + EncounterId.From(guid).Value.Should().Be(guid); + EncounterId.From(guid).Should().Be(EncounterId.From(guid)); + EncounterId.Create().Should().NotBe(EncounterId.Create()); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RejectBlank_When_UseCaseKeyFrom(string value) + { + var act = () => UseCaseKey.From(value); + + act.Should().Throw(); + } + + [Theory] + [InlineData("Kick Bolt")] + [InlineData("kick_bolt")] + [InlineData("-kick")] + public void Should_RejectNonKebab_When_UseCaseKeyFrom(string value) + { + var act = () => UseCaseKey.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_UseCaseKeyExceedsMax() + { + var act = () => UseCaseKey.From(new string('a', UseCaseKey.MaxLength + 1)); + + act.Should().Throw(); + } + + [Fact] + public void Should_NormalizeAndCompareByValue_When_UseCaseKey() + { + UseCaseKey.From(" Kick-Bolt ").Value.Should().Be("kick-bolt"); + UseCaseKey.From("kick-bolt").Should().Be(UseCaseKey.From("KICK-BOLT")); + UseCaseKey.From("kick-bolt").Should().NotBe(UseCaseKey.From("stun-pack")); + } + + [Theory] + [InlineData(-1, 0, 0)] + [InlineData(0, -1, 0)] + [InlineData(0, 0, -1)] + public void Should_RejectNegativeComponent_When_PatchVersionFrom(int major, int minor, int build) + { + var act = () => PatchVersion.From(major, minor, build); + + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("12.0")] + [InlineData("12.0.0.1")] + [InlineData("12.x.0")] + public void Should_RejectMalformed_When_PatchVersionParse(string raw) + { + var act = () => PatchVersion.Parse(raw); + + act.Should().Throw(); + } + + [Fact] + public void Should_RoundTripAndCompareByComponents_When_PatchVersion() + { + PatchVersion.Parse("12.1.3").ToString().Should().Be("12.1.3"); + PatchVersion.From(12, 1, 3).Should().Be(PatchVersion.Parse("12.1.3")); + PatchVersion.From(12, 1, 3).Should().NotBe(PatchVersion.From(12, 1, 4)); + PatchVersion.From(12, 1, 3).Should().NotBe(PatchVersion.From(12, 2, 3)); + PatchVersion.From(12, 1, 3).Should().NotBe(PatchVersion.From(11, 1, 3)); + } + + [Fact] + public void Should_FlagManualOverride_OnlyForManualSource() + { + var at = DateTimeOffset.UnixEpoch; + + Provenance.Manual(at).IsManualOverride.Should().BeTrue(); + Provenance.Blizzard(at).IsManualOverride.Should().BeFalse(); + Provenance.Wago(at).IsManualOverride.Should().BeFalse(); + Provenance.Mined(at).IsManualOverride.Should().BeFalse(); + } + + [Fact] + public void Should_CarrySourceAndTimestamp_When_ProvenanceCreated() + { + var at = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero); + + Provenance.Blizzard(at).Source.Should().Be(ProvenanceSource.BlizzardApi); + Provenance.Manual(at).SyncedAt.Should().Be(at); + Provenance.Wago(at).Source.Should().Be(ProvenanceSource.WagoTools); + Provenance.Mined(at).Source.Should().Be(ProvenanceSource.Mined); + } +} diff --git a/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixIdTests.cs b/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixIdTests.cs new file mode 100644 index 00000000..134dad60 --- /dev/null +++ b/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixIdTests.cs @@ -0,0 +1,40 @@ +namespace Hotfixes.Application.UnitTests.Hotfixes; + +public sealed class HotfixIdTests +{ + [Fact] + public void Should_RejectEmptyGuid_When_FromEmpty() + { + var act = () => HotfixId.From(Guid.Empty); + + act.Should().Throw(); + } + + [Fact] + public void Should_WrapValue_When_FromGuid() + { + var guid = Guid.NewGuid(); + + HotfixId.From(guid).Value.Should().Be(guid); + } + + [Fact] + public void Should_GenerateDistinctIds_When_Created() + { + HotfixId.Create().Should().NotBe(HotfixId.Create()); + } + + [Fact] + public void Should_BeEqual_When_SameValue() + { + var guid = Guid.NewGuid(); + + HotfixId.From(guid).Should().Be(HotfixId.From(guid)); + } + + [Fact] + public void Should_NotBeEqual_When_DifferentValue() + { + HotfixId.From(Guid.NewGuid()).Should().NotBe(HotfixId.From(Guid.NewGuid())); + } +} diff --git a/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixSnapshotTests.cs b/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixSnapshotTests.cs new file mode 100644 index 00000000..122d9035 --- /dev/null +++ b/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixSnapshotTests.cs @@ -0,0 +1,23 @@ +using Hotfixes.Domain.Aggregates.Hotfixes; + +namespace Hotfixes.Application.UnitTests.Hotfixes; + +public sealed class HotfixSnapshotTests +{ + private static readonly DateTime _published = new(2026, 5, 16, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Should_RoundtripThroughSnapshot() + { + var original = Hotfix.Create( + HotfixContext.Pve, "11.0.5 tuning", "Several class changes", _published, _published.AddMinutes(1), + sourceUrl: "https://wow.example/patch").Value!; + + var rehydrated = Hotfix.FromSnapshot(original.ToSnapshot()); + + rehydrated.ToSnapshot().Should().Be(original.ToSnapshot()); + rehydrated.Context.Should().Be(HotfixContext.Pve); + rehydrated.Title.Should().Be("11.0.5 tuning"); + rehydrated.SourceUrl.Should().Be("https://wow.example/patch"); + } +} diff --git a/src/Identity/Identity.Application.UnitTests/Domain/IdentityDomainCoverageTests.cs b/src/Identity/Identity.Application.UnitTests/Domain/IdentityDomainCoverageTests.cs new file mode 100644 index 00000000..661c405c --- /dev/null +++ b/src/Identity/Identity.Application.UnitTests/Domain/IdentityDomainCoverageTests.cs @@ -0,0 +1,282 @@ +using FluentAssertions; +using Identity.Domain.Aggregates.DeviceAuthorizations; +using DeviceAuth = Identity.Domain.Aggregates.DeviceAuthorizations.DeviceAuthorization; +using Identity.Domain.Aggregates.Users; +using Identity.Domain.Aggregates.Users.ValueObjects; +using Identity.Domain.Errors; +using Wow.Kernel.Shared; + +namespace Identity.Application.UnitTests.Domain; + +public sealed class IdentityDomainCoverageTests +{ + private static readonly DateTime _now = new(2026, 5, 16, 12, 0, 0, DateTimeKind.Utc); + + private static DeviceAuth NewAuth( + string deviceCode = "device-code-123", + string userCode = "ABCD-1234", + string clientId = "wow-desktop", + string scope = "openid profile", + TimeSpan? lifetime = null, + int pollIntervalSeconds = 5) => + DeviceAuth.Create(deviceCode, userCode, clientId, scope, _now, + lifetime ?? TimeSpan.FromMinutes(10), pollIntervalSeconds); + + [Fact] + public void Should_CreatePendingAuthorization_When_AllInputsValid() + { + var auth = NewAuth(); + auth.Status.Should().Be(DeviceAuthorizationStatus.Pending); + auth.ExpiresAt.Should().Be(_now.AddMinutes(10)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("short")] // < 8 + [InlineData("bad code!")] // not url-safe + public void Should_RejectDeviceCode_When_Invalid(string deviceCode) + { + ((Action)(() => NewAuth(deviceCode: deviceCode))).Should().Throw(); + } + + [Fact] + public void Should_RejectDeviceCode_When_TooLong() + { + ((Action)(() => NewAuth(deviceCode: new string('a', 257)))).Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("ab")] // < 4 + [InlineData("code with spaces")] // bad shape + public void Should_RejectUserCode_When_Invalid(string userCode) + { + ((Action)(() => NewAuth(userCode: userCode))).Should().Throw(); + } + + [Fact] + public void Should_RejectUserCode_When_TooLong() + { + ((Action)(() => NewAuth(userCode: new string('A', 65)))).Should().Throw(); + } + + [Fact] + public void Should_RejectClientId_When_BlankOrTooLong() + { + ((Action)(() => NewAuth(clientId: ""))).Should().Throw(); + ((Action)(() => NewAuth(clientId: new string('c', 257)))).Should().Throw(); + } + + [Fact] + public void Should_RejectScope_When_TooLong() + { + ((Action)(() => NewAuth(scope: new string('s', 513)))).Should().Throw(); + } + + [Fact] + public void Should_AcceptNullScopeAsEmpty() + { + DeviceAuth.Create("device-code-123", "ABCD-1234", "wow-desktop", null!, _now, + TimeSpan.FromMinutes(10), 5).Scope.Should().BeEmpty(); + } + + [Theory] + [InlineData(0)] + [InlineData(3601)] + public void Should_RejectPollInterval_When_OutOfRange(int interval) + { + ((Action)(() => NewAuth(pollIntervalSeconds: interval))).Should().Throw(); + } + + [Fact] + public void Should_RejectNonPositiveLifetime() + { + ((Action)(() => NewAuth(lifetime: TimeSpan.Zero))).Should().Throw(); + } + + [Fact] + public void Should_ReportExpiry_AtExactBoundary() + { + var auth = NewAuth(lifetime: TimeSpan.FromMinutes(10)); + auth.IsExpired(_now.AddMinutes(10).AddTicks(-1)).Should().BeFalse(); + auth.IsExpired(_now.AddMinutes(10)).Should().BeTrue(); + } + + [Fact] + public void Should_ApprovePendingAuthorization() + { + var auth = NewAuth(); + var userId = UserId.From(Guid.NewGuid()); + + ThenResultIsSuccess(auth.Approve(userId, _now.AddMinutes(1))); + auth.Status.Should().Be(DeviceAuthorizationStatus.Approved); + } + + [Fact] + public void Should_FailApprove_When_Expired() + { + ThenResultIsFailureWithError( + NewAuth().Approve(UserId.From(Guid.NewGuid()), _now.AddMinutes(20)), + IdentityErrors.DeviceAuthorization.Expired); + } + + [Fact] + public void Should_BeIdempotent_When_ApprovingAlreadyApproved() + { + var auth = NewAuth(); + auth.Approve(UserId.From(Guid.NewGuid()), _now); + + ThenResultIsSuccess(auth.Approve(UserId.From(Guid.NewGuid()), _now)); + } + + [Fact] + public void Should_FailApprove_When_AlreadyDenied() + { + var auth = NewAuth(); + auth.Deny(_now); + + ThenResultIsFailureWithError(auth.Approve(UserId.From(Guid.NewGuid()), _now), + IdentityErrors.DeviceAuthorization.NotPending); + } + + [Fact] + public void Should_DenyPending_And_RejectDenyingNonPending() + { + var auth = NewAuth(); + ThenResultIsSuccess(auth.Deny(_now)); + auth.Status.Should().Be(DeviceAuthorizationStatus.Denied); + + ThenResultIsFailureWithError(auth.Deny(_now), IdentityErrors.DeviceAuthorization.NotPending); + } + + [Fact] + public void Should_MarkConsumed_OnlyWhenApproved() + { + var pending = NewAuth(); + ThenResultIsFailureWithError(pending.MarkConsumed(_now), IdentityErrors.DeviceAuthorization.NotApproved); + + var approved = NewAuth(); + approved.Approve(UserId.From(Guid.NewGuid()), _now); + ThenResultIsSuccess(approved.MarkConsumed(_now)); + approved.Status.Should().Be(DeviceAuthorizationStatus.Consumed); + } + + [Fact] + public void Should_AcceptDeviceAuthInputs_AtExactBoundaries() + { + ((Action)(() => DeviceAuth.Create(new string('a', 8), new string('A', 4), new string('c', 256), + new string('s', 512), _now, TimeSpan.FromSeconds(1), 1))).Should().NotThrow(); + ((Action)(() => DeviceAuth.Create(new string('a', 256), new string('A', 64), "client", + "scope", _now, TimeSpan.FromSeconds(1), 3600))).Should().NotThrow(); + } + + [Fact] + public void Should_RejectEmptyAndCompareByValue_When_DeviceAuthorizationId() + { + ((Action)(() => DeviceAuthorizationId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + DeviceAuthorizationId.From(guid).Should().Be(DeviceAuthorizationId.From(guid)); + DeviceAuthorizationId.Create().Should().NotBe(DeviceAuthorizationId.Create()); + } + + // ---- WowCharacter boundaries ---- + + private static Result Character(string name = "Thrall", int level = 80, + string playableClass = "Shaman", string faction = "Horde") => + WowCharacter.Create(1, name, "stormrage", "Stormrage", level, playableClass, "Orc", faction); + + [Fact] + public void Should_AcceptWowCharacter_AtNameAndLevelBoundaries() + { + Character(name: "ab", level: 1).IsSuccess.Should().BeTrue(); + Character(name: new string('a', 12), level: 80).IsSuccess.Should().BeTrue(); + } + + [Theory] + [InlineData("a", 80)] + [InlineData("aaaaaaaaaaaaa", 80)] + [InlineData("Thrall", 0)] + [InlineData("Thrall", 81)] + public void Should_RejectWowCharacter_OutsideNameOrLevelBounds(string name, int level) + { + Character(name: name, level: level).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectWowCharacter_When_ClassOrFactionUnknown() + { + Character(playableClass: "Tinker").IsFailure.Should().BeTrue(); + Character(faction: "Pandaren").IsFailure.Should().BeTrue(); + } + + // ---- BattleTag ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("NoHash")] + [InlineData("#1234")] // hash at start + [InlineData("Name#")] // hash at end + public void Should_RejectBattleTag_When_Invalid(string value) + { + ((Action)(() => BattleTag.From(value))).Should().Throw(); + } + + [Fact] + public void Should_RejectBattleTag_When_TooLong() + { + ((Action)(() => BattleTag.From(new string('a', 33) + "#1"))).Should().Throw(); + } + + [Fact] + public void Should_AcceptAndCompareBattleTag() + { + BattleTag.From("Thrall#1234").Value.Should().Be("Thrall#1234"); + BattleTag.From("Thrall#1234").Should().Be(BattleTag.From("Thrall#1234")); + BattleTag.From("Thrall#1234").Should().NotBe(BattleTag.From("Jaina#5678")); + } + + // ---- DisplayName ---- + + [Theory] + [InlineData("")] + [InlineData("ab")] // < 3 + public void Should_RejectDisplayName_When_TooShortOrBlank(string value) + { + DisplayName.Create(value).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectDisplayName_When_TooLong() + { + DisplayName.Create(new string('a', 33)).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_AcceptDisplayName_AtBoundaries() + { + DisplayName.Create("abc").IsSuccess.Should().BeTrue(); + DisplayName.Create(new string('a', 32)).IsSuccess.Should().BeTrue(); + } + + // ---- Bio ---- + + [Fact] + public void Should_NormalizeNullBioToEmpty() + { + Bio.Create(null!).Value!.Value.Should().BeEmpty(); + } + + [Fact] + public void Should_RejectBio_When_TooLong() + { + Bio.Create(new string('a', 501)).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_AcceptBio_AtMaxLength() + { + Bio.Create(new string('a', 500)).IsSuccess.Should().BeTrue(); + } +} diff --git a/src/Identity/Identity.Application.UnitTests/Domain/UserAggregateTests.cs b/src/Identity/Identity.Application.UnitTests/Domain/UserAggregateTests.cs new file mode 100644 index 00000000..4d574fa9 --- /dev/null +++ b/src/Identity/Identity.Application.UnitTests/Domain/UserAggregateTests.cs @@ -0,0 +1,196 @@ +using FluentAssertions; +using Identity.Domain.Aggregates.Users; +using Identity.Domain.Aggregates.Users.ValueObjects; +using Identity.Domain.Errors; + +namespace Identity.Application.UnitTests.Domain; + +public sealed class UserAggregateTests +{ + private static readonly DateTime _now = new(2026, 5, 16, 12, 0, 0, DateTimeKind.Utc); + + private static User NewUser() => + User.Create(EmailAddress.Create("coach@wow.gg").Value!, HashedPassword.From("$argon2-local-hash"), _now).Value!; + + // ---- VerifyPasswordResetCode ---- + + [Fact] + public void Should_FailReset_When_NoCodeRequested() + { + ThenResultIsFailureWithError(NewUser().VerifyPasswordResetCode("hash", _now), IdentityErrors.Token.Invalid); + } + + [Fact] + public void Should_FailReset_When_Expired() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(11)), IdentityErrors.Token.Expired); + } + + [Fact] + public void Should_FailReset_When_WrongCode() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("wrong", _now.AddMinutes(1)), IdentityErrors.Token.Invalid); + } + + [Fact] + public void Should_FailReset_When_TooManyAttempts() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + for (var i = 0; i < 5; i++) + user.VerifyPasswordResetCode("wrong", _now.AddMinutes(1)); + + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(1)), IdentityErrors.Token.Invalid); + } + + [Fact] + public void Should_SucceedReset_When_CorrectCodeWithinWindow() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + + ThenResultIsSuccess(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(1))); + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(2)), IdentityErrors.Token.Invalid); + } + + [Fact] + public void Should_FailReset_AtExactExpiryInstant() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(10)), IdentityErrors.Token.Expired); + } + + // ---- ConsumeMagicLink ---- + + private static User PasswordlessUser() => User.CreatePasswordless(EmailAddress.Create("ml@wow.gg").Value!, _now).Value!; + + [Fact] + public void Should_VerifyEmailAndActivate_When_MagicLinkConsumed() + { + var user = PasswordlessUser(); + user.RequestMagicLink("enc", "tokhash", _now.AddMinutes(15), _now, returnUrl: null); + + var result = user.ConsumeMagicLink("tokhash", _now.AddMinutes(1)); + + ThenResultIsSuccess(result); + user.EmailVerified.Should().BeTrue(); + user.Status.Should().Be(UserStatus.Active); + user.DomainEvents.OfType().Should().ContainSingle(); + } + + [Fact] + public void Should_FailMagicLink_When_AlreadyConsumedOrExpiredOrWrong() + { + var fresh = PasswordlessUser(); + ThenResultIsFailureWithError(fresh.ConsumeMagicLink("x", _now), IdentityErrors.MagicLink.AlreadyConsumed); + + var expired = PasswordlessUser(); + expired.RequestMagicLink("enc", "tokhash", _now.AddMinutes(15), _now, null); + ThenResultIsFailureWithError(expired.ConsumeMagicLink("tokhash", _now.AddMinutes(16)), IdentityErrors.MagicLink.Expired); + + var wrong = PasswordlessUser(); + wrong.RequestMagicLink("enc", "tokhash", _now.AddMinutes(15), _now, null); + ThenResultIsFailureWithError(wrong.ConsumeMagicLink("nope", _now.AddMinutes(1)), IdentityErrors.MagicLink.Invalid); + } + + [Fact] + public void Should_FailMagicLink_When_AccountDisabled() + { + var user = PasswordlessUser(); + user.RequestMagicLink("enc", "tokhash", _now.AddMinutes(15), _now, null); + user.Disable(); + + ThenResultIsFailureWithError(user.ConsumeMagicLink("tokhash", _now.AddMinutes(1)), IdentityErrors.User.AccountDisabled); + } + + // ---- AddLocalPassword / RehashPassword ---- + + [Fact] + public void Should_AddLocalPassword_When_AccountHasNoLocalPassword() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + + var registeredBefore = user.DomainEvents.OfType().Count(); + var result = user.AddLocalPassword(HashedPassword.From("$argon2-new")); + + ThenResultIsSuccess(result); + user.Status.Should().Be(UserStatus.PendingEmailVerification); + user.EmailVerified.Should().BeFalse(); + user.DomainEvents.OfType().Count() + .Should().Be(registeredBefore + 1); + } + + [Fact] + public void Should_FailAddLocalPassword_When_AlreadyHasLocalPassword() + { + ThenResultIsFailureWithError(NewUser().AddLocalPassword(HashedPassword.From("$argon2-2")), IdentityErrors.User.AlreadyExists); + } + + [Fact] + public void Should_ReplaceHashSilently_When_Rehashed() + { + var user = NewUser(); + var eventsBefore = user.DomainEvents.Count; + user.RehashPassword(HashedPassword.From("$argon2-rehashed")); + + user.PasswordHash.Value.Should().Be("$argon2-rehashed"); + user.DomainEvents.Count.Should().Be(eventsBefore); + } + + // ---- CreateFromBattleNet / RecordBattleNetLogin ---- + + [Fact] + public void Should_CreateActiveVerifiedUser_When_FromBattleNet() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + + user.Status.Should().Be(UserStatus.Active); + user.EmailVerified.Should().BeTrue(); + user.BattleTag!.Value.Should().Be("Thrall#1234"); + user.DomainEvents.OfType().Should().ContainSingle(); + user.DomainEvents.OfType() + .Should().ContainSingle().Which.BlizzardId.Should().Be(42); + user.DomainEvents.OfType() + .Should().ContainSingle().Which.FirstLogin.Should().BeTrue(); + } + + [Fact] + public void Should_KeepBattleTag_When_LoginUsesSameTag() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + + ThenResultIsSuccess(user.RecordBattleNetLogin(BattleTag.From("Thrall#1234"), _now.AddDays(1))); + user.BattleTag!.Value.Should().Be("Thrall#1234"); + } + + [Fact] + public void Should_RecordLogin_When_BattleNetUserAuthenticates() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + + var result = user.RecordBattleNetLogin(BattleTag.From("Thrall#9999"), _now.AddDays(1)); + + ThenResultIsSuccess(result); + user.BattleTag!.Value.Should().Be("Thrall#9999"); + user.LastLoginAt.Should().Be(_now.AddDays(1)); + user.DomainEvents.OfType() + .Last().FirstLogin.Should().BeFalse(); + } + + [Fact] + public void Should_FailBattleNetLogin_When_Disabled() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + user.Disable(); + + ThenResultIsFailureWithError(user.RecordBattleNetLogin(BattleTag.From("Thrall#1234"), _now), IdentityErrors.User.AccountDisabled); + } +} diff --git a/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/Compliance/ComplianceServiceCollectionExtensionsTests.cs b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/Compliance/ComplianceServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..1025ea99 --- /dev/null +++ b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/Compliance/ComplianceServiceCollectionExtensionsTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Wow.Kernel.Shared.Compliance; +using Xunit; + +namespace Wow.Kernel.Shared.UnitTests.Compliance; + +public sealed class ComplianceServiceCollectionExtensionsTests +{ + private static IConfiguration Config(string? hmacKey) + { + var values = new Dictionary(); + if (hmacKey is not null) + values[ComplianceServiceCollectionExtensions.HmacKeyConfigPath] = hmacKey; + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + [Fact] + public void Should_Throw_When_HmacKeyMissing() + { + ((Action)(() => new ServiceCollection().AddWowRedaction(Config(null)))) + .Should().Throw(); + } + + [Fact] + public void Should_Throw_When_HmacKeyBlank() + { + ((Action)(() => new ServiceCollection().AddWowRedaction(Config(" ")))) + .Should().Throw(); + } + + [Fact] + public void Should_Throw_When_HmacKeyNotBase64() + { + ((Action)(() => new ServiceCollection().AddWowRedaction(Config("not valid base64!!!")))) + .Should().Throw(); + } + + [Fact] + public void Should_RegisterRedactorProvider_When_HmacKeyValid() + { + var validKey = Convert.ToBase64String(new byte[64]); + var services = new ServiceCollection(); + + services.AddWowRedaction(Config(validKey)); + + services.BuildServiceProvider().GetService().Should().NotBeNull(); + } +} diff --git a/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/KernelCoverageTests.cs b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/KernelCoverageTests.cs new file mode 100644 index 00000000..73f3a868 --- /dev/null +++ b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/KernelCoverageTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using Wow.Kernel.Shared.Compliance; +using Wow.Kernel.Shared.Wow; +using Wow.Kernel.Shared.Wow.Requirements; +using Xunit; + +namespace Wow.Kernel.Shared.UnitTests; + +public sealed class KernelCoverageTests +{ + // ---- RunOutcome ---- + + [Theory] + [InlineData(RunOutcome.Timed, true)] + [InlineData(RunOutcome.Depleted, true)] + [InlineData(RunOutcome.Abandoned, false)] + public void Should_ReportCompletion_When_RunOutcomeChecked(RunOutcome outcome, bool completed) + { + outcome.IsCompleted().Should().Be(completed); + } + + [Theory] + [InlineData(1, RunOutcome.Timed)] + [InlineData(2, RunOutcome.Depleted)] + [InlineData(3, RunOutcome.Abandoned)] + public void Should_ParseKnownInt_When_RunOutcomeFromInt(int value, RunOutcome expected) + { + RunOutcomeExtensions.FromInt(value).Should().Be(expected); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + public void Should_Throw_When_RunOutcomeFromUnknownInt(int value) + { + ((Action)(() => RunOutcomeExtensions.FromInt(value))).Should().Throw(); + } + + // ---- WowSpecInfo.FromClassAndSpec ---- + + [Theory] + [InlineData("", "Arms")] + [InlineData("Warrior", "")] + [InlineData(" ", "Arms")] + [InlineData("Warrior", "Unknownspec")] + public void Should_ReturnNull_When_ClassOrSpecMissingOrUnknown(string @class, string spec) + { + WowSpecInfo.FromClassAndSpec(@class, spec).Should().BeNull(); + } + + [Fact] + public void Should_ResolveSpec_CaseAndSeparatorInsensitive() + { + WowSpecInfo.FromClassAndSpec("Warrior", "Arms").Should().Be(WowSpec.ArmsWarrior); + WowSpecInfo.FromClassAndSpec("warrior", "arms").Should().Be(WowSpec.ArmsWarrior); + WowSpecInfo.FromClassAndSpec("Death Knight", "Blood").Should().Be(WowSpec.BloodDeathKnight); + } + + // ---- RequirementEvaluator PvP boundary ---- + + [Fact] + public void Should_Satisfy_When_PvpRatingExactlyMeetsThreshold() + { + var requirement = new PvpRatingRequirement(MinRating: 2400, Bracket: PvpBracket.ThreeVsThree); + var state = new PlayerState( + OwnedMountBlizzardIds: new HashSet(), + CompletedAchievementBlizzardIds: new HashSet(), + PvpRatings: new Dictionary { [PvpBracket.ThreeVsThree] = 2400 }); + + RequirementEvaluator.Evaluate(requirement, state).IsSatisfied.Should().BeTrue(); + } + + // ---- TruncatingRedactor ---- + + [Fact] + public void Should_TruncateLongValue_KeepingPrefixAndEllipsis() + { + var redactor = new TruncatingRedactor(); + var source = "supersecrettoken-1234567890".AsSpan(); + var destination = new char[redactor.GetRedactedLength(source)]; + + var written = redactor.Redact(source, destination); + + written.Should().Be(9); // 8-char prefix + single ellipsis char + new string(destination, 0, written).Should().Be("supersec…"); + } + + [Fact] + public void Should_ReturnValueAsIs_When_ShorterThanPrefix() + { + var redactor = new TruncatingRedactor(); + var source = "short".AsSpan(); + var destination = new char[redactor.GetRedactedLength(source)]; + + redactor.Redact(source, destination).Should().Be(5); + } +} diff --git a/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/WowExpansionTests.cs b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/WowExpansionTests.cs new file mode 100644 index 00000000..dd4ff1bf --- /dev/null +++ b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/WowExpansionTests.cs @@ -0,0 +1,32 @@ +using Wow.Kernel.Shared.Wow; +using Xunit; + +namespace Wow.Kernel.Shared.UnitTests; + +public sealed class WowExpansionTests +{ + [Theory] + [InlineData(WowExpansion.Classic, "classic")] + [InlineData(WowExpansion.TheBurningCrusade, "tbc")] + [InlineData(WowExpansion.WrathOfTheLichKing, "wotlk")] + [InlineData(WowExpansion.Cataclysm, "cata")] + [InlineData(WowExpansion.MistsOfPandaria, "mop")] + [InlineData(WowExpansion.WarlordsOfDraenor, "wod")] + [InlineData(WowExpansion.Legion, "legion")] + [InlineData(WowExpansion.BattleForAzeroth, "bfa")] + [InlineData(WowExpansion.Shadowlands, "sl")] + [InlineData(WowExpansion.Dragonflight, "df")] + [InlineData(WowExpansion.TheWarWithin, "tww")] + [InlineData(WowExpansion.Midnight, "midnight")] + [InlineData(WowExpansion.TheLastTitan, "tlt")] + public void Should_MapEveryExpansionToSlug(WowExpansion expansion, string slug) + { + expansion.ToSlug().Should().Be(slug); + } + + [Fact] + public void Should_Throw_When_ExpansionUnknown() + { + ((Action)(() => ((WowExpansion)999).ToSlug())).Should().Throw(); + } +} diff --git a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Fakes/RecordingMediator.cs b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Fakes/RecordingMediator.cs deleted file mode 100644 index 3f12a267..00000000 --- a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Fakes/RecordingMediator.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Wow.Kernel.Shared; -using Wow.Kernel.Shared.Mediator; - -namespace KnowledgeMining.Application.UnitTests.Fakes; - -public sealed class RecordingMediator : IMediator -{ - public List Commands { get; } = []; - - public Task> SendAsync(TRequest request, CancellationToken cancellationToken = default) - where TRequest : notnull - { - Commands.Add(request); - return Task.FromResult(Result.Success(default!)); - } - - public Task> ExecuteAsync(TCommand command, CancellationToken cancellationToken = default) - where TCommand : notnull - { - Commands.Add(command); - return Task.FromResult(Result.Success(default!)); - } - - public Task ExecuteAsync(TCommand command, CancellationToken cancellationToken = default) - where TCommand : notnull - { - Commands.Add(command); - return Task.FromResult(Result.Success()); - } -} diff --git a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/AcceptCandidateUseCaseTests.cs b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/AcceptCandidateUseCaseTests.cs index 7c0feeb7..27b82c56 100644 --- a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/AcceptCandidateUseCaseTests.cs +++ b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/AcceptCandidateUseCaseTests.cs @@ -5,14 +5,13 @@ public sealed class AcceptCandidateUseCaseTests private static readonly DateTime _now = new(2026, 5, 16, 12, 0, 0, DateTimeKind.Utc); private readonly FakeCandidateRepository _repository = new(); - private readonly RecordingMediator _mediator = new(); private readonly TrackingOutboxUnitOfWork _unitOfWork = new(); private readonly FakeCurrentDateTimeProvider _clock = FakeCurrentDateTimeProvider.At(_now); private readonly AcceptCandidateUseCase _useCase; public AcceptCandidateUseCaseTests() { - _useCase = new AcceptCandidateUseCase(_repository, _mediator, _unitOfWork, _clock); + _useCase = new AcceptCandidateUseCase(_repository, _unitOfWork, _clock); } #region Tests @@ -35,31 +34,6 @@ public async Task Should_FlipStatusToMerged_When_AcceptedWithoutEncounterResolut stored.ResolvedAt.Should().Be(new DateTimeOffset(_now, TimeSpan.Zero)); } - [Fact] - public async Task Should_NotDispatchAttachUseCase_When_EncounterResolutionReturnsNull() - { - var candidate = NewSuggestedCandidate(); - _repository.Seed(candidate); - - await _useCase.HandleAsync(new AcceptCandidateCommand( - candidate.Id, Guid.NewGuid())); - - _mediator.Commands.Should().BeEmpty(); - } - - [Fact] - public async Task Should_NotDispatchAttachUseCase_When_MergeIntoExistingUseCaseIsTrue() - { - var candidate = NewSuggestedCandidate(); - _repository.Seed(candidate); - - await _useCase.HandleAsync(new AcceptCandidateCommand( - candidate.Id, Guid.NewGuid(), "merged", MergeIntoExistingUseCase: true)); - - _mediator.Commands.Should().BeEmpty(); - _repository.All.Single().Status.Should().Be((int)CandidateStatus.Merged); - } - [Fact] public async Task Should_TrackCandidateInOutbox_When_AcceptedSuccessfully() { diff --git a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/BehavioralPatternCandidateTests.cs b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/BehavioralPatternCandidateTests.cs new file mode 100644 index 00000000..d138c4c4 --- /dev/null +++ b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/BehavioralPatternCandidateTests.cs @@ -0,0 +1,140 @@ +using Wow.Kernel.Shared; + +namespace KnowledgeMining.Application.UnitTests.Mining; + +public sealed class BehavioralPatternCandidateTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private static Result Raise( + PatternKind kind = PatternKind.ImportantSpell, + int encounterId = 3009, + int difficulty = 5, + int[]? targets = null, + int[]? responses = null, + double lift = 3.0, + double support = 0.5, + Guid[]? runs = null) => + BehavioralPatternCandidate.Raise( + kind, encounterId, difficulty, phaseOrdinal: null, pullSignature: null, + targets ?? [47568], responses ?? [], + lift, support, _at, runs ?? [Guid.NewGuid()]); + + [Fact] + public void Should_RaiseSuggestedCandidate_When_EvidenceIsValid() + { + var candidate = Raise().Value!; + + candidate.Status.Should().Be(CandidateStatus.Suggested); + candidate.DomainEvents.OfType() + .Should().ContainSingle(); + } + + [Fact] + public void Should_FailWithInvalidEvidence_When_PatternKindNotDefined() => + ThenResultIsFailureWithError(Raise(kind: (PatternKind)999), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Theory] + [InlineData(0, 5)] + [InlineData(-1, 5)] + [InlineData(3009, 0)] + [InlineData(3009, -1)] + public void Should_FailWithInvalidEvidence_When_EncounterOrDifficultyNonPositive(int encounterId, int difficulty) => + ThenResultIsFailureWithError(Raise(encounterId: encounterId, difficulty: difficulty), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Fact] + public void Should_FailWithInvalidEvidence_When_NoTargetOrResponseSpells() => + ThenResultIsFailureWithError(Raise(targets: [], responses: []), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Theory] + [InlineData(0d)] + [InlineData(-1d)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void Should_FailWithInvalidLift_When_LiftNotFiniteOrNonPositive(double lift) => + ThenResultIsFailureWithError(Raise(lift: lift), + KnowledgeMiningErrors.Candidate.InvalidLift); + + [Theory] + [InlineData(-0.1d)] + [InlineData(1.1d)] + [InlineData(double.NaN)] + public void Should_FailWithInvalidEvidence_When_SupportOutOfUnitRange(double support) => + ThenResultIsFailureWithError(Raise(support: support), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Fact] + public void Should_FailWithInvalidEvidence_When_NoSupportingRuns() => + ThenResultIsFailureWithError(Raise(runs: []), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Fact] + public void Should_EncodeKindEncounterAndFirstSpell_When_BuildingMinedUseCaseKey() + { + var candidate = Raise(kind: PatternKind.ImportantSpell, encounterId: 3009, targets: [47568, 999]).Value!; + + candidate.MinedUseCaseKey().Should().Be("mined-importantspell-3009-47568"); + } + + [Fact] + public void Should_FlipToAccepted_When_AcceptedWithCreatedUseCase() + { + var candidate = Raise().Value!; + + var result = candidate.Accept(Guid.NewGuid(), createdUseCaseId: Guid.NewGuid(), _at, "promoted"); + + ThenResultIsSuccess(result); + candidate.Status.Should().Be(CandidateStatus.Accepted); + candidate.DomainEvents.OfType() + .Should().ContainSingle(); + } + + [Fact] + public void Should_FlipToMerged_When_AcceptedWithoutCreatedUseCase() + { + var candidate = Raise().Value!; + + candidate.Accept(Guid.NewGuid(), createdUseCaseId: null, _at); + + candidate.Status.Should().Be(CandidateStatus.Merged); + } + + [Fact] + public void Should_FailToAccept_When_AlreadyResolved() + { + var candidate = Raise().Value!; + candidate.Reject(Guid.NewGuid(), _at, "noise"); + + ThenResultIsFailureWithError( + candidate.Accept(Guid.NewGuid(), null, _at), + KnowledgeMiningErrors.Candidate.AlreadyResolved); + } + + [Fact] + public void Should_FlipToRejected_When_Rejected() + { + var candidate = Raise().Value!; + + var result = candidate.Reject(Guid.NewGuid(), _at, "false positive"); + + ThenResultIsSuccess(result); + candidate.Status.Should().Be(CandidateStatus.Rejected); + candidate.ResolutionNote.Should().Be("false positive"); + candidate.DomainEvents.OfType() + .Should().ContainSingle(); + } + + [Fact] + public void Should_FailToReject_When_AlreadyResolved() + { + var candidate = Raise().Value!; + candidate.Accept(Guid.NewGuid(), Guid.NewGuid(), _at); + + ThenResultIsFailureWithError( + candidate.Reject(Guid.NewGuid(), _at, "late"), + KnowledgeMiningErrors.Candidate.AlreadyResolved); + } +} diff --git a/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateCommand.cs b/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateCommand.cs index 58604e3b..038cb10d 100644 --- a/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateCommand.cs +++ b/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateCommand.cs @@ -3,5 +3,4 @@ namespace KnowledgeMining.Application.UseCases.Accept; public sealed record AcceptCandidateCommand( Guid CandidateId, Guid ReviewerId, - string? Note = null, - bool MergeIntoExistingUseCase = false); + string? Note = null); diff --git a/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateUseCase.cs b/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateUseCase.cs index 8fe9074b..a6fdc5ab 100644 --- a/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateUseCase.cs +++ b/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateUseCase.cs @@ -1,34 +1,18 @@ -using EncounterKnowledge.Application.UseCases.Encounters.Commands.AttachUseCase; using KnowledgeMining.Application.Ports; using KnowledgeMining.Domain.Aggregates.PatternCandidates; using KnowledgeMining.Domain.Errors; using KnowledgeMining.Domain.ValueObjects; using Wow.Kernel.Shared; -using Wow.Kernel.Shared.Mediator; using Wow.Kernel.Shared.Outbox; -using EkEncounterId = EncounterKnowledge.Domain.Aggregates.Encounters.EncounterId; -using EkProvenanceSource = EncounterKnowledge.Domain.ValueObjects.ProvenanceSource; -using EkUseCaseKey = EncounterKnowledge.Domain.ValueObjects.UseCaseKey; -using EkUseCaseKind = EncounterKnowledge.Domain.ValueObjects.UseCaseKind; namespace KnowledgeMining.Application.UseCases.Accept; /// -/// Promotes a curator-reviewed candidate into a real BehavioralUseCase -/// on EncounterKnowledge (unless -/// is true, in which case the use case status flips to Merged and no attach -/// is dispatched — the merge target is updated by the curator separately -/// via AttachUseCaseCommand with a manual override). -/// -/// The attach carries ProvenanceSource = Mined so the resulting -/// behavioral use case is auditable as machine-suggested (curator-approved), -/// not curator-authored from scratch. A subsequent manual edit by the -/// curator will flip the provenance to Manual via the domain rule defined -/// in commit 17d94a9. +/// Promotes a curator-reviewed candidate: flips its status to Merged and +/// records the reviewer and the optional resolution note. /// public sealed class AcceptCandidateUseCase( ICandidateRepository candidateRepository, - IMediator mediator, IOutboxUnitOfWork unitOfWork, ICurrentDateTimeProvider clock) : IUseCase { @@ -41,30 +25,9 @@ public async Task HandleAsync( if (candidate is null) return Result.Failure(KnowledgeMiningErrors.Candidate.NotFound); - Guid? createdUseCaseId = null; - if (!command.MergeIntoExistingUseCase) - { - var encounterId = await ResolveEncounterIdAsync( - candidate.BlizzardEncounterId, candidate.Difficulty, cancellationToken); - if (encounterId.HasValue) - { - createdUseCaseId = Guid.NewGuid(); - await mediator.ExecuteAsync(new AttachUseCaseCommand( - EncounterId: EkEncounterId.From(encounterId.Value), - Key: EkUseCaseKey.From(candidate.MinedUseCaseKey()), - Kind: MapKind(candidate.PatternKind), - PhaseOrdinal: candidate.PhaseOrdinal, - TargetSpellBlizzardIds: candidate.TargetSpellBlizzardIds, - ExpectedResponseBlizzardIds: candidate.ExpectedResponseBlizzardIds, - Priority: 5, - ProvenanceSource: EkProvenanceSource.Mined, - ReviewedBy: command.ReviewerId), cancellationToken); - } - } - var resolveResult = candidate.Accept( command.ReviewerId, - createdUseCaseId, + createdUseCaseId: null, new DateTimeOffset(clock.UtcNow, TimeSpan.Zero), command.Note); if (resolveResult.IsFailure) return resolveResult; @@ -76,25 +39,4 @@ await unitOfWork.ExecuteAsync( return Result.Success(); } - - /// - /// Resolves the EncounterKnowledge aggregate id for a (blizzardEncounterId, - /// difficulty) tuple by walking the mediator into EncounterKnowledge's read - /// model. Implemented as a port lookup by the caller in test settings; - /// here we just return null when the lookup is not wired — the candidate - /// transitions to Merged with no attach. Production wiring resolves the id - /// via a small read query (see follow-up commit). - /// - private static Task ResolveEncounterIdAsync( - int blizzardEncounterId, int difficulty, CancellationToken cancellationToken) - => Task.FromResult(null); - - private static EkUseCaseKind MapKind(PatternKind kind) => kind switch - { - PatternKind.ImportantSpell => EkUseCaseKind.Cooldown, - PatternKind.DangerSpell => EkUseCaseKind.Defensive, - PatternKind.BehavioralSequence => EkUseCaseKind.Interrupt, - PatternKind.DiminishingReturnsGroup => EkUseCaseKind.AoEStun, - _ => EkUseCaseKind.Cooldown, - }; } diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionBoundaryVoTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionBoundaryVoTests.cs new file mode 100644 index 00000000..c913e2a8 --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionBoundaryVoTests.cs @@ -0,0 +1,178 @@ +using LogIngestion.Domain.Aggregates; + +namespace LogIngestion.Application.UnitTests.Domain; + +public sealed class LogIngestionBoundaryVoTests +{ + // ---- ShareToken ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("short")] // < 16 + [InlineData("0123456789012345678901234567890123456789012345678901234567890123456789")] // > 64 + [InlineData("token-with-bad-char!!")] // '!' not url-safe + [InlineData("token with spaces 00")] // space not url-safe + public void Should_RejectShareToken_When_Invalid(string value) + { + ShareToken.Create(value).IsFailure.Should().BeTrue(); + } + + [Theory] + [InlineData("ABCDEFGHIJKLMNOP")] // exactly 16, upper + [InlineData("abcdefghijklmnop")] // lower + [InlineData("0123456789-_aaaa")] // digits, hyphen, underscore + public void Should_AcceptShareToken_When_UrlSafeAndWithinLength(string value) + { + ShareToken.Create(value).Value!.Value.Should().Be(value); + } + + [Fact] + public void Should_TrimAndCompareByValue_When_ShareToken() + { + ShareToken.Create(" abcdefghijklmnop ").Value!.Value.Should().Be("abcdefghijklmnop"); + ShareToken.Create("abcdefghijklmnop").Value! + .Should().Be(ShareToken.Create("abcdefghijklmnop").Value!); + } + + [Theory] + [InlineData('@')] // just before 'A' + [InlineData('[')] // just after 'Z' + [InlineData('`')] // just before 'a' + [InlineData('{')] // just after 'z' + [InlineData('/')] // just before '0' + [InlineData(':')] // just after '9' + public void Should_RejectShareToken_When_CharOutsideUrlSafeRanges(char bad) + { + ShareToken.Create(new string('a', 15) + bad).IsFailure.Should().BeTrue(); + } + + // ---- SegmentHash ---- + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(63)] + [InlineData(65)] + public void Should_RejectSegmentHash_When_WrongLength(int length) + { + ((Action)(() => SegmentHash.From(new string('a', length)))).Should().Throw(); + } + + [Theory] + [InlineData('A')] // uppercase not allowed (lowercase hex only) + [InlineData('g')] // just after 'f' + [InlineData('/')] // just before '0' + [InlineData(':')] // just after '9' + public void Should_RejectSegmentHash_When_NonLowercaseHexChar(char bad) + { + var value = new string('a', 63) + bad; + + ((Action)(() => SegmentHash.From(value))).Should().Throw(); + } + + [Fact] + public void Should_AcceptSegmentHash_When_ExactlySixtyFourLowercaseHex() + { + var value = string.Concat(Enumerable.Repeat("0123456789abcdef", 4)); + + SegmentHash.From(value).Value.Should().Be(value); + } + + [Fact] + public void Should_CompareByValue_When_SegmentHash() + { + var value = new string('a', 64); + SegmentHash.From(value).Should().Be(SegmentHash.From(value)); + SegmentHash.From(value).Should().NotBe(SegmentHash.From(new string('b', 64))); + } + + // ---- UploadJournalEntry ---- + + private static SegmentHash Hash() => SegmentHash.From(new string('a', 64)); + + [Fact] + public void Should_CreateJournalEntry_When_AllFieldsValid() + { + var userId = OwnerUserId.From(Guid.NewGuid()); + var runId = Guid.NewGuid(); + var at = new DateTimeOffset(2026, 5, 19, 0, 0, 0, TimeSpan.Zero); + + var entry = UploadJournalEntry.Create(userId, Hash(), runId, IngestedSegmentKind.Raid, at); + + entry.RunId.Should().Be(runId); + entry.Kind.Should().Be(IngestedSegmentKind.Raid); + entry.IngestedAt.Should().Be(at); + } + + [Fact] + public void Should_RejectJournalEntry_When_RunIdEmpty() + { + ((Action)(() => UploadJournalEntry.Create( + OwnerUserId.From(Guid.NewGuid()), Hash(), Guid.Empty, IngestedSegmentKind.Raid, DateTimeOffset.UnixEpoch))) + .Should().Throw(); + } + + [Fact] + public void Should_RejectJournalEntry_When_KindUndefined() + { + ((Action)(() => UploadJournalEntry.Create( + OwnerUserId.From(Guid.NewGuid()), Hash(), Guid.NewGuid(), (IngestedSegmentKind)99, DateTimeOffset.UnixEpoch))) + .Should().Throw(); + } + + [Fact] + public void Should_RejectJournalEntry_When_IngestedAtDefault() + { + ((Action)(() => UploadJournalEntry.Create( + OwnerUserId.From(Guid.NewGuid()), Hash(), Guid.NewGuid(), IngestedSegmentKind.MythicPlus, default))) + .Should().Throw(); + } + + [Fact] + public void Should_RoundtripThroughSnapshot_When_JournalEntryRehydrated() + { + var original = UploadJournalEntry.Create( + OwnerUserId.From(Guid.NewGuid()), Hash(), Guid.NewGuid(), + IngestedSegmentKind.MythicPlus, new DateTimeOffset(2026, 5, 19, 0, 0, 0, TimeSpan.Zero)); + + var rehydrated = UploadJournalEntry.FromSnapshot(original.ToSnapshot()); + + rehydrated.ToSnapshot().Should().Be(original.ToSnapshot()); + } + + [Fact] + public void Should_RejectSnapshot_When_RunIdEmpty() + { + var corrupt = new LogIngestion.Domain.Snapshots.UploadJournalEntrySnapshot( + Guid.NewGuid(), Guid.NewGuid(), new string('a', 64), Guid.Empty, (int)IngestedSegmentKind.Raid, + new DateTimeOffset(2026, 5, 19, 0, 0, 0, TimeSpan.Zero)); + + ((Action)(() => UploadJournalEntry.FromSnapshot(corrupt))).Should().Throw(); + } + + // ---- PlayerLoadout ---- + + [Fact] + public void Should_CompareByItemsAndTalents_When_PlayerLoadout() + { + var a = new PlayerLoadout([new EquippedItem(1, 600)], [new TalentEntry(10, 20, 1)]); + var b = new PlayerLoadout([new EquippedItem(1, 600)], [new TalentEntry(10, 20, 1)]); + var differentItem = new PlayerLoadout([new EquippedItem(2, 600)], [new TalentEntry(10, 20, 1)]); + var differentTalent = new PlayerLoadout([new EquippedItem(1, 600)], [new TalentEntry(10, 20, 2)]); + + a.Should().Be(b); + a.Should().NotBe(differentItem); + a.Should().NotBe(differentTalent); + } + + [Fact] + public void Should_CopyInputCollections_When_PlayerLoadoutConstructed() + { + var items = new List { new(1, 600) }; + var loadout = new PlayerLoadout(items, []); + items.Add(new EquippedItem(2, 610)); + + loadout.Items.Should().HaveCount(1); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionDomainCoverageTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionDomainCoverageTests.cs new file mode 100644 index 00000000..78c1986f --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionDomainCoverageTests.cs @@ -0,0 +1,202 @@ +using LogIngestion.Domain.Aggregates; + +namespace LogIngestion.Application.UnitTests.Domain; + +public sealed class LogIngestionDomainCoverageTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private static RunPlayer Player(string name = "Thrall", string realm = "stormrage", WowSpec spec = WowSpec.RestorationShaman) => + RunPlayer.Create(name, realm, spec).Value!; + + private static DungeonRun KeystoneRun() => + DungeonRun.Create( + DungeonRunId.New(), + DungeonSlug.FromRawName("The Stonevault"), + KeyLevel.From(18).Value!, + AffixWeek.From([9, 10, 152]).Value!, + ContentDifficulty.MythicPlus, + [Player()], + _at, + instanceId: 2649); + + // ---- DungeonRun.Complete / Abandon ---- + + [Fact] + public void Should_MarkTimed_When_CompletedInTime() + { + var run = KeystoneRun(); + + run.Complete(timed: true, durationMs: 1_500_000, completedAt: _at.AddMinutes(25)); + + var evt = run.DomainEvents.OfType() + .Should().ContainSingle().Subject; + evt.Timed.Should().BeTrue(); + evt.KeyLevel.Should().Be(18); + evt.AffixIds.Should().Equal(9, 10, 152); + evt.DurationMs.Should().Be(1_500_000); + run.ToSnapshot().Timed.Should().BeTrue(); + } + + [Fact] + public void Should_MarkDepleted_When_CompletedOverTime() + { + var run = KeystoneRun(); + + run.Complete(timed: false, durationMs: 2_000_000, completedAt: _at.AddMinutes(35)); + + run.ToSnapshot().Timed.Should().BeFalse(); + run.DomainEvents.OfType() + .Single().Timed.Should().BeFalse(); + } + + [Fact] + public void Should_MarkAbandonedWithoutCompletion_When_Abandoned() + { + var run = KeystoneRun(); + + run.Abandon(endedAt: _at.AddMinutes(10), durationMs: 600_000); + + var snapshot = run.ToSnapshot(); + snapshot.CompletedAt.Should().BeNull(); + snapshot.DurationMs.Should().Be(600_000); + run.DomainEvents.OfType() + .Should().ContainSingle().Which.KeyLevel.Should().Be(18); + } + + [Fact] + public void Should_RaiseEventOnlyOnRealChange_When_VisibilityChanged() + { + var run = KeystoneRun(); + + run.ChangeVisibility(LogVisibility.Public); + run.DomainEvents.OfType().Should().BeEmpty(); + + run.ChangeVisibility(LogVisibility.Private); + run.DomainEvents.OfType().Should().ContainSingle(); + } + + [Fact] + public void Should_RejectMixedKeystoneState_When_DungeonRunCreated() + { + ((Action)(() => DungeonRun.Create(DungeonRunId.New(), DungeonSlug.FromRawName("X"), + KeyLevel.From(18).Value!, null, ContentDifficulty.MythicPlus, [Player()], _at, 1))) + .Should().Throw(); + } + + // ---- OwnerUserId / GuildId ---- + + [Fact] + public void Should_RejectEmptyAndCompareByValue_When_OwnerUserId() + { + ((Action)(() => OwnerUserId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + OwnerUserId.From(guid).Should().Be(OwnerUserId.From(guid)); + OwnerUserId.From(Guid.NewGuid()).Should().NotBe(OwnerUserId.From(Guid.NewGuid())); + } + + [Fact] + public void Should_RejectEmptyAndCompareByValue_When_GuildId() + { + ((Action)(() => GuildId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + GuildId.From(guid).Should().Be(GuildId.From(guid)); + GuildId.From(Guid.NewGuid()).Should().NotBe(GuildId.From(Guid.NewGuid())); + } + + // ---- UploadJournalEntry null + corrupt snapshot ---- + + private static SegmentHash Hash() => SegmentHash.From(new string('a', 64)); + + [Fact] + public void Should_ThrowArgumentNull_When_UploadJournalEntryRequiredArgNull() + { + ((Action)(() => UploadJournalEntry.Create(null!, Hash(), Guid.NewGuid(), IngestedSegmentKind.Raid, _at))) + .Should().Throw(); + ((Action)(() => UploadJournalEntry.Create(OwnerUserId.From(Guid.NewGuid()), null!, Guid.NewGuid(), IngestedSegmentKind.Raid, _at))) + .Should().Throw(); + } + + [Fact] + public void Should_RejectCorruptSnapshot_When_KindUndefinedOrTimestampDefault() + { + var baseSnap = new LogIngestion.Domain.Snapshots.UploadJournalEntrySnapshot( + Guid.NewGuid(), Guid.NewGuid(), new string('a', 64), Guid.NewGuid(), (int)IngestedSegmentKind.Raid, _at); + + ((Action)(() => UploadJournalEntry.FromSnapshot(baseSnap with { Kind = 99 }))).Should().Throw(); + ((Action)(() => UploadJournalEntry.FromSnapshot(baseSnap with { IngestedAt = default }))).Should().Throw(); + } + + // ---- RunPlayer ---- + + [Theory] + [InlineData("a")] // shorter than min (2) + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] // 33 > max (32) + public void Should_RejectName_When_OutsideLengthBounds(string name) + { + RunPlayer.Create(name, "stormrage", WowSpec.FrostMage).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_AcceptName_AtLengthBoundaries() + { + RunPlayer.Create("ab", "stormrage", WowSpec.FrostMage).IsSuccess.Should().BeTrue(); + RunPlayer.Create(new string('a', 32), "stormrage", WowSpec.FrostMage).IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_RunPlayerEquality() + { + Player().Should().Be(Player()); + Player().Should().NotBe(Player(name: "Jaina")); + Player().Should().NotBe(Player(realm: "hyjal")); + Player().Should().NotBe(Player(spec: WowSpec.FrostMage)); + Player().Should().NotBe(RunPlayer.Create("Thrall", "stormrage", WowSpec.RestorationShaman, + new PlayerLoadout([new EquippedItem(1, 600)], [])).Value!); + } + + // ---- SeasonSlug ---- + + [Fact] + public void Should_NormalizeDiacriticsApostrophesAndSpaces_When_SeasonSlugFromRawName() + { + SeasonSlug.FromRawName("Tarrac Frozén — L'Île").Value + .Should().MatchRegex("^[a-z0-9]+(-[a-z0-9]+)*$"); + } + + [Fact] + public void Should_CollapseConsecutiveSeparators_When_SeasonSlug() + { + SeasonSlug.FromRawName("A &&& B").Value.Should().Be("a-b"); + } + + [Fact] + public void Should_RejectEmptyOrTooLong_When_SeasonSlug() + { + ((Action)(() => SeasonSlug.FromRawName(" "))).Should().Throw(); + ((Action)(() => SeasonSlug.Reconstitute(""))).Should().Throw(); + ((Action)(() => SeasonSlug.Reconstitute(new string('a', 65)))).Should().Throw(); + } + + [Fact] + public void Should_CompareByValue_When_SeasonSlug() + { + SeasonSlug.FromRawName("Season One").Should().Be(SeasonSlug.Reconstitute("season-one")); + } + + // ---- ShareToken boundary chars ---- + + [Theory] + [InlineData('A')] + [InlineData('Z')] + [InlineData('a')] + [InlineData('z')] + [InlineData('0')] + [InlineData('9')] + [InlineData('-')] + [InlineData('_')] + public void Should_AcceptToken_When_AllCharsAtUrlSafeBoundaries(char c) + { + ShareToken.Create(new string(c, 16)).IsSuccess.Should().BeTrue(); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs new file mode 100644 index 00000000..cb7327f7 --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs @@ -0,0 +1,317 @@ +using EventStore.Application.UnitTests.Fakes; +using LogIngestion.Application.UseCases.Logs.Commands.IngestLog.Strategies; +using LogIngestion.Domain.Errors; + +namespace LogIngestion.Application.UnitTests.Ingestion; + +public sealed class LogIngestionStrategyTests +{ + private static readonly DateTimeOffset _start = new(2026, 5, 16, 20, 0, 0, TimeSpan.Zero); + private static readonly CancellationToken _ct = CancellationToken.None; + + private static IngestionCombatant Player(string name = "Thrall") => + new(name, "stormrage", WowSpec.RestorationShaman); + + private static IngestionCombatEvent Event() => + new("SPELL_CAST_SUCCESS", _start.AddSeconds(10), "src", "Thrall", "tgt", "Boss", 12345, "Kick", 0, 0); + + // ---- Raid strategy ---- + + private readonly FakeRaidSessionRepository _raidSessions = new(); + private readonly FakeDungeonRunRepository _dungeonRuns = new(); + private readonly FakeCombatEventRepository _combatEvents = new(); + private readonly FakeOutboxUnitOfWork _unitOfWork = new(); + + private RaidLogIngestionStrategy RaidStrategy() => new(_raidSessions, _combatEvents, _unitOfWork); + private MythicPlusLogIngestionStrategy MplusStrategy() => new(_dungeonRuns, _combatEvents, _unitOfWork); + + private static RaidIngestionInput Raid( + int instanceId = 1273, + IReadOnlyList? pulls = null) => + new() + { + Source = IngestionSource.CombatLogFile, + RaidName = "Manaforge Omega", + InstanceId = instanceId, + Difficulty = ContentDifficulty.Heroic, + SessionStartedAt = _start, + Pulls = pulls ?? + [ + new IngestionPull(3009, "Boss", _start, _start.AddMinutes(5), true, null, [Player()], [Event()]), + ], + }; + + [Fact] + public async Task Should_FailWithIncompleteLog_When_RaidStrategyGivenMythicPlusInput() + { + var result = await RaidStrategy().ExecuteAsync(Mplus(), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.IncompleteLog); + } + + [Fact] + public async Task Should_FailWithDuplicateRun_When_RaidFingerprintAlreadyIngested() + { + _raidSessions.SeedExistingFingerprint(1273, _start); + + var result = await RaidStrategy().ExecuteAsync(Raid(), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.DuplicateRun); + } + + [Fact] + public async Task Should_FailWithInvalidPlayer_When_RaidPullHasUnnamedPlayer() + { + var pulls = new IngestionPull[] + { + new(3009, "Boss", _start, _start.AddMinutes(5), true, null, [new IngestionCombatant("", "stormrage", WowSpec.RestorationShaman)], []), + }; + + var result = await RaidStrategy().ExecuteAsync(Raid(pulls: pulls), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.InvalidPlayer); + } + + [Fact] + public async Task Should_PersistSessionAndEvents_When_RaidIngestionSucceeds() + { + var owner = Guid.NewGuid(); + var guild = Guid.NewGuid(); + + var result = await RaidStrategy().ExecuteAsync( + Raid(), new IngestionContext(OwnerUserId: owner, GuildId: guild), _ct); + + var value = ThenResultIsSuccess(result); + value.Type.Should().Be(IngestedLogType.Raid); + value.PullIds.Should().HaveCount(1); + + var snapshot = _raidSessions.GetSnapshot(RaidSessionId.From(value.Id)); + snapshot.InstanceId.Should().Be(1273); + snapshot.Difficulty.Should().Be((int)ContentDifficulty.Heroic); + snapshot.OwnerUserId.Should().Be(owner); + snapshot.GuildId.Should().Be(guild); + var pull = snapshot.Encounters.Single().Pulls.Single(); + pull.Success.Should().BeTrue(); + var player = pull.Players.Should().ContainSingle().Subject; + player.Name.Should().Be("Thrall"); + player.Realm.Should().Be("stormrage"); + + _combatEvents.SaveBatchCallCount.Should().Be(1); + var record = _combatEvents.GetByUnit(value.PullIds[0]).Single(); + record.EventType.Should().Be("SPELL_CAST_SUCCESS"); + record.SpellId.Should().Be(12345); + record.TimestampMs.Should().Be(10_000); + _unitOfWork.CapturedEvents.Should().NotBeEmpty(); + } + + [Fact] + public async Task Should_NotPersistEvents_When_PullHasNoEvents() + { + var pulls = new IngestionPull[] + { + new(3009, "Boss", _start, _start.AddMinutes(5), true, null, [Player()], []), + }; + + await RaidStrategy().ExecuteAsync(Raid(pulls: pulls), new IngestionContext(), _ct); + + _combatEvents.SaveBatchCallCount.Should().Be(0); + } + + [Fact] + public async Task Should_RecordPull_When_EndedExactlyEqualsStarted() + { + var pulls = new IngestionPull[] + { + new(3009, "Instant", _start, _start, true, null, [Player()], []), + }; + + var result = await RaidStrategy().ExecuteAsync(Raid(pulls: pulls), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + value.PullIds.Should().HaveCount(1); + } + + [Fact] + public async Task Should_SkipPull_When_EndedBeforeStarted() + { + var pulls = new IngestionPull[] + { + new(3009, "Good", _start, _start.AddMinutes(5), true, null, [Player()], []), + new(3010, "Malformed", _start.AddMinutes(10), _start.AddMinutes(9), false, 50, [Player()], []), + }; + + var result = await RaidStrategy().ExecuteAsync(Raid(pulls: pulls), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + value.PullIds.Should().HaveCount(1); + } + + // ---- Mythic+ strategy ---- + + private static MythicPlusIngestionInput Mplus( + int instanceId = 2649, + int keyLevel = 15, + IReadOnlyList? affixIds = null, + ContentDifficulty difficulty = ContentDifficulty.MythicPlus, + IReadOnlyList? combatants = null, + IReadOnlyList? events = null, + MythicPlusOutcome? outcome = null) => + new() + { + Source = IngestionSource.CombatLogFile, + DungeonName = "Priory of the Sacred Flame", + InstanceId = instanceId, + KeyLevel = keyLevel, + AffixIds = affixIds ?? [9, 10], + Difficulty = difficulty, + StartedAt = _start, + Combatants = combatants ?? [Player()], + Events = events ?? [], + Outcome = outcome, + }; + + private static MythicPlusOutcome Completed() => new() + { + Status = MythicPlusOutcomeStatus.Completed, + DurationMs = 1_500_000, + EndedAt = _start.AddMinutes(25), + Timed = true, + }; + + [Fact] + public async Task Should_FailWithIncompleteLog_When_MplusStrategyGivenRaidInput() + { + var result = await MplusStrategy().ExecuteAsync(Raid(), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.IncompleteLog); + } + + [Fact] + public async Task Should_FailWithDuplicateRun_When_MplusFingerprintAlreadyIngested() + { + _dungeonRuns.SeedExistingFingerprint(2649, _start); + + var result = await MplusStrategy().ExecuteAsync(Mplus(), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.DuplicateRun); + } + + [Fact] + public async Task Should_FailWithInvalidPlayer_When_MplusHasUnnamedPlayer() + { + var result = await MplusStrategy().ExecuteAsync( + Mplus(combatants: [new IngestionCombatant("", "stormrage", WowSpec.FrostMage)]), + new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.InvalidPlayer); + } + + [Fact] + public async Task Should_PersistRunAndEvents_When_CompletedMythicPlusSucceeds() + { + var owner = Guid.NewGuid(); + + var result = await MplusStrategy().ExecuteAsync( + Mplus(events: [Event()], outcome: Completed()), + new IngestionContext(OwnerUserId: owner), _ct); + + var value = ThenResultIsSuccess(result); + value.Type.Should().Be(IngestedLogType.MythicPlus); + + var snapshot = _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)); + snapshot.InstanceId.Should().Be(2649); + snapshot.Difficulty.Should().Be((int)ContentDifficulty.MythicPlus); + snapshot.KeyLevel.Should().Be(15); + snapshot.OwnerUserId.Should().Be(owner); + snapshot.Timed.Should().BeTrue(); + snapshot.DurationMs.Should().Be(1_500_000); + + _combatEvents.SaveBatchCallCount.Should().Be(1); + _combatEvents.GetByUnit(value.Id).Single().SpellId.Should().Be(12345); + _unitOfWork.CapturedEvents.Should().NotBeEmpty(); + } + + [Fact] + public async Task Should_NotBatchEvents_When_MythicPlusHasNoEvents() + { + await MplusStrategy().ExecuteAsync(Mplus(events: [], outcome: Completed()), new IngestionContext(), _ct); + + _combatEvents.SaveBatchCallCount.Should().Be(0); + } + + [Fact] + public async Task Should_PersistWithoutOutcome_When_MythicPlusStillInProgress() + { + var result = await MplusStrategy().ExecuteAsync(Mplus(outcome: null), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)).CompletedAt.Should().BeNull(); + } + + [Fact] + public async Task Should_MarkUntimed_When_CompletedOutcomeHasNoTimedFlag() + { + var outcome = new MythicPlusOutcome + { + Status = MythicPlusOutcomeStatus.Completed, + DurationMs = 1_500_000, + EndedAt = _start.AddMinutes(30), + Timed = null, + }; + + var result = await MplusStrategy().ExecuteAsync(Mplus(outcome: outcome), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)).Timed.Should().BeFalse(); + } + + [Fact] + public async Task Should_PersistRun_When_AbandonedMythicPlus() + { + var abandoned = new MythicPlusOutcome + { + Status = MythicPlusOutcomeStatus.Abandoned, + DurationMs = 600_000, + EndedAt = _start.AddMinutes(10), + }; + + var result = await MplusStrategy().ExecuteAsync( + Mplus(outcome: abandoned), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + var snapshot = _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)); + snapshot.Timed.Should().BeFalse(); + snapshot.DurationMs.Should().Be(600_000); + } + + [Fact] + public async Task Should_NotRequireKeystone_When_MythicZero() + { + var result = await MplusStrategy().ExecuteAsync( + Mplus(keyLevel: 0, affixIds: [], difficulty: ContentDifficulty.MythicZero, outcome: Completed()), + new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + var snapshot = _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)); + snapshot.KeyLevel.Should().BeNull(); + snapshot.AffixIds.Should().BeNull(); + } + + [Fact] + public async Task Should_FailWithInvalidKeyLevel_When_KeyLevelBelowMinimum() + { + var result = await MplusStrategy().ExecuteAsync( + Mplus(keyLevel: 1), new IngestionContext(), _ct); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task Should_FailWithInvalidAffixes_When_AffixCountOutOfRange() + { + var result = await MplusStrategy().ExecuteAsync( + Mplus(affixIds: []), new IngestionContext(), _ct); + + result.IsFailure.Should().BeTrue(); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs new file mode 100644 index 00000000..624c85fb --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs @@ -0,0 +1,165 @@ +namespace LogIngestion.Application.UnitTests.Ingestion; + +public sealed class SegmentHasherTests +{ + private static readonly DateTimeOffset _start = new(2026, 5, 16, 20, 0, 0, TimeSpan.Zero); + + private static IngestionCombatant Player(string name) => + new(name, "stormrage", WowSpec.RestorationShaman); + + private static MythicPlusIngestionInput Mplus( + int instanceId = 2649, + DateTimeOffset? startedAt = null, + DateTimeOffset? endedAt = null, + params string[] players) => + new() + { + Source = IngestionSource.CombatLogFile, + DungeonName = "Priory", + InstanceId = instanceId, + KeyLevel = 15, + AffixIds = [9, 10], + Difficulty = ContentDifficulty.MythicPlus, + StartedAt = startedAt ?? _start, + Combatants = [.. (players.Length == 0 ? ["Thrall", "Jaina"] : players).Select(Player)], + Events = [], + Outcome = endedAt is null ? null : new MythicPlusOutcome + { + Status = MythicPlusOutcomeStatus.Completed, + DurationMs = 1000, + EndedAt = endedAt.Value, + Timed = true, + }, + }; + + private static IngestionPull Pull(int encounterId, DateTimeOffset startedAt, DateTimeOffset endedAt) => + new(encounterId, "Boss", startedAt, endedAt, Success: true, WipePercent: null, Players: [], Events: []); + + private static RaidIngestionInput Raid( + int instanceId = 1273, + ContentDifficulty difficulty = ContentDifficulty.Heroic, + DateTimeOffset? startedAt = null, + IReadOnlyList? pulls = null) => + new() + { + Source = IngestionSource.CombatLogFile, + RaidName = "Manaforge", + InstanceId = instanceId, + Difficulty = difficulty, + SessionStartedAt = startedAt ?? _start, + Pulls = pulls ?? [Pull(3009, _start, _start.AddMinutes(5))], + }; + + [Fact] + public void Should_ProduceIdenticalHash_When_SameMythicPlusSegmentHashedTwice() + { + SegmentHasher.Compute(Mplus()).Should().Be(SegmentHasher.Compute(Mplus())); + } + + [Fact] + public void Should_BeInvariantToCombatantOrder_When_HashingMythicPlus() + { + SegmentHasher.Compute(Mplus(players: ["Thrall", "Jaina"])) + .Should().Be(SegmentHasher.Compute(Mplus(players: ["Jaina", "Thrall"]))); + } + + [Fact] + public void Should_Differentiate_When_MythicPlusInstanceDiffers() + { + SegmentHasher.Compute(Mplus(instanceId: 2649)) + .Should().NotBe(SegmentHasher.Compute(Mplus(instanceId: 2650))); + } + + [Fact] + public void Should_Differentiate_When_MythicPlusStartDiffers() + { + SegmentHasher.Compute(Mplus(startedAt: _start)) + .Should().NotBe(SegmentHasher.Compute(Mplus(startedAt: _start.AddSeconds(1)))); + } + + [Fact] + public void Should_Differentiate_When_MythicPlusOutcomeEndDiffers() + { + SegmentHasher.Compute(Mplus(endedAt: _start.AddMinutes(20))) + .Should().NotBe(SegmentHasher.Compute(Mplus(endedAt: _start.AddMinutes(25)))); + } + + [Fact] + public void Should_Differentiate_When_MythicPlusCompositionDiffers() + { + SegmentHasher.Compute(Mplus(players: ["Thrall", "Jaina"])) + .Should().NotBe(SegmentHasher.Compute(Mplus(players: ["Thrall", "Sylvanas"]))); + } + + [Fact] + public void Should_FallBackToStart_When_MythicPlusHasNoOutcome() + { + SegmentHasher.Compute(Mplus(endedAt: null)).Should().NotBeNull(); + } + + [Fact] + public void Should_ProduceIdenticalHash_When_SameRaidSegmentHashedTwice() + { + SegmentHasher.Compute(Raid()).Should().Be(SegmentHasher.Compute(Raid())); + } + + [Fact] + public void Should_BeInvariantToPullOrder_When_HashingRaid() + { + var p1 = Pull(3009, _start, _start.AddMinutes(5)); + var p2 = Pull(3010, _start.AddMinutes(6), _start.AddMinutes(10)); + + SegmentHasher.Compute(Raid(pulls: [p1, p2])) + .Should().Be(SegmentHasher.Compute(Raid(pulls: [p2, p1]))); + } + + [Fact] + public void Should_Differentiate_When_RaidDifficultyDiffers() + { + SegmentHasher.Compute(Raid(difficulty: ContentDifficulty.Heroic)) + .Should().NotBe(SegmentHasher.Compute(Raid(difficulty: ContentDifficulty.Mythic))); + } + + [Fact] + public void Should_Differentiate_When_RaidInstanceDiffers() + { + SegmentHasher.Compute(Raid(instanceId: 1273)) + .Should().NotBe(SegmentHasher.Compute(Raid(instanceId: 1280))); + } + + [Fact] + public void Should_Differentiate_When_RaidSessionStartDiffers() + { + SegmentHasher.Compute(Raid(startedAt: _start)) + .Should().NotBe(SegmentHasher.Compute(Raid(startedAt: _start.AddSeconds(1)))); + } + + [Fact] + public void Should_Differentiate_When_RaidPullStartDiffers() + { + var a = Raid(pulls: [Pull(3009, _start, _start.AddMinutes(5))]); + var b = Raid(pulls: [Pull(3009, _start.AddSeconds(1), _start.AddMinutes(5))]); + + SegmentHasher.Compute(a).Should().NotBe(SegmentHasher.Compute(b)); + } + + [Fact] + public void Should_Differentiate_When_RaidLastPullEndDiffers() + { + SegmentHasher.Compute(Raid(pulls: [Pull(3009, _start, _start.AddMinutes(5))])) + .Should().NotBe(SegmentHasher.Compute(Raid(pulls: [Pull(3009, _start, _start.AddMinutes(9))]))); + } + + [Fact] + public void Should_NotThrowAndAnchorOnSessionStart_When_RaidHasNoPulls() + { + SegmentHasher.Compute(Raid(pulls: [])).Should().NotBeNull(); + } + + [Fact] + public void Should_DiscriminateRaidFromMythicPlus_When_SameInstanceAndStart() + { + SegmentHasher.Compute(Mplus(instanceId: 5, startedAt: _start)) + .Should().NotBe(SegmentHasher.Compute(Raid(instanceId: 5, startedAt: _start))); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/GetRaidSessionPlayerNamesQueryTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/GetRaidSessionPlayerNamesQueryTests.cs new file mode 100644 index 00000000..b8477c6b --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/GetRaidSessionPlayerNamesQueryTests.cs @@ -0,0 +1,33 @@ +using LogIngestion.Application.UseCases.RaidSessions.Queries.GetRaidSessionPlayerNames; + +namespace LogIngestion.Application.UnitTests.Queries; + +public sealed class GetRaidSessionPlayerNamesQueryTests +{ + private readonly FakeRaidSessionRepository _repository = new(); + private readonly GetRaidSessionPlayerNamesQuery _query; + + public GetRaidSessionPlayerNamesQueryTests() + { + _query = new GetRaidSessionPlayerNamesQuery(_repository); + } + + [Fact] + public async Task Should_ReturnDistinctPlayerNames_When_SessionExists() + { + var snapshot = _repository.SeedValidSession(); + + var result = await _query.HandleAsync(new GetRaidSessionPlayerNamesRequest(snapshot.Id)); + + var names = ThenResultIsSuccess(result); + names.PlayerNames.Should().Contain("Thrall"); + } + + [Fact] + public async Task Should_FailWithSessionNotFound_When_SessionMissing() + { + var result = await _query.HandleAsync(new GetRaidSessionPlayerNamesRequest(Guid.NewGuid())); + + ThenResultIsFailureWithError(result, LogIngestion.Domain.Errors.LogIngestionErrors.SessionNotFound); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/ListUserUploadsQueryTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/ListUserUploadsQueryTests.cs new file mode 100644 index 00000000..d7ecf4c8 --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/ListUserUploadsQueryTests.cs @@ -0,0 +1,60 @@ +using LogIngestion.Application.UseCases.Uploads.Queries.ListUserUploads; +using LogIngestion.Domain.Aggregates; + +namespace LogIngestion.Application.UnitTests.Queries; + +public sealed class ListUserUploadsQueryTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private readonly FakeUploadJournalRepository _journal = new(); + private readonly ListUserUploadsQuery _query; + private readonly Guid _userId = Guid.NewGuid(); + + public ListUserUploadsQueryTests() + { + _query = new ListUserUploadsQuery(_journal); + } + + private async Task Seed(int count) + { + for (var i = 0; i < count; i++) + { + await _journal.SaveAsync(UploadJournalEntry.Create( + OwnerUserId.From(_userId), + SegmentHash.From(new string((char)('a' + i), 64)), + Guid.NewGuid(), + IngestedSegmentKind.Raid, + _at.AddMinutes(i))); + } + } + + [Fact] + public async Task Should_ProjectJournalEntries_When_UserHasUploads() + { + await Seed(2); + + var result = await _query.HandleAsync(new ListUserUploadsRequest(_userId)); + + var summaries = ThenResultIsSuccess(result); + summaries.Should().HaveCount(2); + summaries[0].Kind.Should().Be(IngestedSegmentKind.Raid); + summaries[0].SegmentHash.Should().HaveLength(64); + } + + [Fact] + public async Task Should_ClampTakeToAtLeastOne_When_TakeIsZeroOrNegative() + { + await Seed(2); + + var result = await _query.HandleAsync(new ListUserUploadsRequest(_userId, Take: 0)); + + ThenResultIsSuccess(result).Should().ContainSingle(); + } + + [Fact] + public async Task Should_ReturnEmpty_When_UserHasNoUploads() + { + ThenResultIsSuccess(await _query.HandleAsync(new ListUserUploadsRequest(Guid.NewGuid()))).Should().BeEmpty(); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs new file mode 100644 index 00000000..237731f5 --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs @@ -0,0 +1,135 @@ +using System.IO.Compression; +using System.Text; +using LogIngestion.Application.Ports; +using LogIngestion.Application.UseCases.Logs.Commands.CompleteResumableUpload; +using LogIngestion.Application.UseCases.Logs.Commands.IngestLog; +using LogIngestion.Application.UseCases.Logs.Commands.UploadCombatLog; +using LogIngestion.Domain.Errors; +using Wow.Kernel.Shared; +using Wow.Kernel.Shared.Localization; +using Wow.Kernel.Shared.Mediator; + +namespace LogIngestion.Application.UnitTests.UseCases; + +public sealed class CompleteResumableUploadHandlerTests +{ + private static readonly DateTime _now = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc); + private readonly FakeCurrentDateTimeProvider _clock = FakeCurrentDateTimeProvider.At(_now); + private readonly StubResultStore _resultStore = new(); + private readonly Guid _owner = Guid.NewGuid(); + + private static Func> PlainContent(string text) => + _ => Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(text))); + + private static Func> GzipContent(string text) => + _ => + { + var buffer = new MemoryStream(); + using (var gz = new GZipStream(buffer, CompressionMode.Compress, leaveOpen: true)) + { + var bytes = Encoding.UTF8.GetBytes(text); + gz.Write(bytes, 0, bytes.Length); + } + buffer.Position = 0; + return Task.FromResult(buffer); + }; + + private CompleteResumableUploadHandler Handler(Result outcome) => + new(new StubMediator(outcome), _resultStore, _clock); + + private static ResumableUploadContext Context(Guid owner, bool gzip = false) + { + var metadata = new Dictionary(); + if (gzip) + metadata["contentEncoding"] = "gzip"; + return new ResumableUploadContext("upload-1", owner, metadata); + } + + [Fact] + public async Task Should_StoreSuccessResult_When_PipelineSucceeds() + { + var pipelineResult = new UploadCombatLogResult( + [new UploadedSegment(IngestedLogType.Raid, Guid.NewGuid(), new string('a', 64))], []); + + await Handler(Result.Success(pipelineResult)) + .HandleAsync(Context(_owner), PlainContent("COMBATLOG"), CancellationToken.None); + + _resultStore.SavedResult.Should().NotBeNull(); + _resultStore.SavedResult!.Value.ownerUserId.Should().Be(_owner); + _resultStore.SavedError.Should().BeNull(); + } + + [Fact] + public async Task Should_StoreErrorKey_When_PipelineFails() + { + await Handler(Result.Failure(LogIngestionErrors.IncompleteLog)) + .HandleAsync(Context(_owner), PlainContent("bad"), CancellationToken.None); + + _resultStore.SavedError.Should().NotBeNull(); + _resultStore.SavedError!.Value.errorKey.Should().Be(LogIngestionErrors.IncompleteLog); + _resultStore.SavedResult.Should().BeNull(); + } + + [Fact] + public async Task Should_DecompressGzip_When_ContentEncodingIsGzip() + { + var mediator = new StubMediator(Result.Success( + new UploadCombatLogResult([], []))); + var handler = new CompleteResumableUploadHandler(mediator, _resultStore, _clock); + + await handler.HandleAsync(Context(_owner, gzip: true), GzipContent("COMBATLOG GZIP"), CancellationToken.None); + + mediator.LastReadContent.Should().Contain("COMBATLOG GZIP"); + } + + [Fact] + public async Task Should_ReadRaw_When_ContentEncodingIsNotGzip() + { + var mediator = new StubMediator(Result.Success(new UploadCombatLogResult([], []))); + var handler = new CompleteResumableUploadHandler(mediator, _resultStore, _clock); + var metadata = new Dictionary(); + metadata["contentEncoding"] = "identity"; + var ctx = new ResumableUploadContext("upload-1", _owner, metadata); + + await handler.HandleAsync(ctx, PlainContent("PLAIN COMBATLOG"), CancellationToken.None); + + mediator.LastReadContent.Should().Be("PLAIN COMBATLOG"); + } + + private sealed class StubResultStore : IUploadResultStore + { + public (string uploadId, Guid ownerUserId, UploadCombatLogResult result)? SavedResult { get; private set; } + public (string uploadId, Guid ownerUserId, TranslationKey errorKey)? SavedError { get; private set; } + + public void SaveResult(string uploadId, Guid ownerUserId, UploadCombatLogResult result, DateTimeOffset now) + => SavedResult = (uploadId, ownerUserId, result); + + public void SaveError(string uploadId, Guid ownerUserId, TranslationKey errorKey, DateTimeOffset now) + => SavedError = (uploadId, ownerUserId, errorKey); + + public UploadResultEntry? Get(string uploadId, Guid ownerUserId, DateTimeOffset now) => null; + } + + private sealed class StubMediator(Result outcome) : IMediator + { + public string? LastReadContent { get; private set; } + + public Task> ExecuteAsync( + TCommand command, CancellationToken cancellationToken = default) + where TCommand : notnull + { + if (command is UploadCombatLogCommand upload) + LastReadContent = upload.LogReader.ReadToEnd(); + return Task.FromResult((Result)(object)outcome); + } + + public Task ExecuteAsync(TCommand command, CancellationToken cancellationToken = default) + where TCommand : notnull + => Task.FromResult(Result.Success()); + + public Task> SendAsync( + TRequest request, CancellationToken cancellationToken = default) + where TRequest : notnull + => throw new NotSupportedException(); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/UploadCombatLogUseCaseTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/UploadCombatLogUseCaseTests.cs index eadd973c..f8fda8a9 100644 --- a/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/UploadCombatLogUseCaseTests.cs +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/UploadCombatLogUseCaseTests.cs @@ -114,6 +114,36 @@ public async Task Should_AssociateJournalEntryWithUser_When_Persisted() _journal.Entries.Single().UserId.Value.Should().Be(_userId); } + [Fact] + public async Task Should_TreatAsDedup_When_StrategyReportsDuplicateRun() + { + var segment = BuildMythicPlus(instanceId: 2660, startedAt: _now); + _dungeonRuns.SeedExistingFingerprint(2660, _now); + _parser.SetupManyRuns(segment); + + var result = await WhenUploading(); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Ingested.Should().BeEmpty(); + result.Value.Deduped.Should().ContainSingle().Which.Id.Should().Be(Guid.Empty); + _journal.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task Should_AbortWithError_When_StrategyFailsWithNonDuplicate() + { + var segment = BuildMythicPlus(instanceId: 2660, startedAt: _now) with + { + Combatants = [new IngestionCombatant("", "Stormrage", WowSpec.FrostMage)], + }; + _parser.SetupManyRuns(segment); + + var result = await WhenUploading(); + + ThenResultIsFailureWithError(result, LogIngestionErrors.InvalidPlayer); + _journal.Entries.Should().BeEmpty(); + } + private Task> WhenUploading() => _useCase.HandleAsync(new UploadCombatLogCommand( new StringReader("ignored — fake parser drives results"), diff --git a/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeAggregateTests.cs b/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeAggregateTests.cs new file mode 100644 index 00000000..ada3ab49 --- /dev/null +++ b/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeAggregateTests.cs @@ -0,0 +1,292 @@ +using Wow.Kernel.Shared; + +namespace SpellKnowledge.Application.UnitTests.Spells.Domain; + +public sealed class SpellKnowledgeAggregateTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + // ---- SpellSchool ---- + + [Theory] + [InlineData(-1)] + [InlineData(7)] + public void Should_RejectUnknownCode_When_SpellSchoolFromCode(int code) + { + ((Action)(() => SpellSchool.FromCode(code))).Should().Throw(); + } + + [Fact] + public void Should_MapEveryCode_When_SpellSchoolFromCode() + { + SpellSchool.FromCode(0).Should().Be(SpellSchool.Physical); + SpellSchool.FromCode(6).Should().Be(SpellSchool.Arcane); + } + + [Theory] + [InlineData("physical")] + [InlineData("HOLY")] + [InlineData(" Fire ")] + [InlineData("nature")] + [InlineData("frost")] + [InlineData("shadow")] + [InlineData("arcane")] + public void Should_ResolveKnownName_When_SpellSchoolFromName(string name) + { + SpellSchool.FromName(name).Should().NotBeNull(); + } + + [Fact] + public void Should_ThrowOrReturnFalse_When_SpellSchoolNameUnknown() + { + ((Action)(() => SpellSchool.FromName("chaos"))).Should().Throw(); + SpellSchool.TryFromName("chaos", out _).Should().BeFalse(); + SpellSchool.TryFromName(null, out _).Should().BeFalse(); + } + + [Fact] + public void Should_CompareByCode_When_SpellSchool() + { + SpellSchool.FromCode(2).Should().Be(SpellSchool.Fire); + SpellSchool.Fire.Should().NotBe(SpellSchool.Frost); + } + + // ---- SpellAttributes bounds ---- + + [Fact] + public void Should_AcceptBoundaryGroupAndBit_And_RejectOutOfRange() + { + var attrs = SpellAttributes.Empty(); + + ((Action)(() => attrs.HasFlag(0, 0))).Should().NotThrow(); + ((Action)(() => attrs.HasFlag(7, 0))).Should().NotThrow(); + ((Action)(() => attrs.HasFlag(7, 31))).Should().NotThrow(); + ((Action)(() => attrs.HasFlag(SpellAttributes.DefaultAttributeGroupCount, 0))) + .Should().Throw(); + } + + [Fact] + public void Should_ThrowArgumentNull_When_SpellAttributesFromNull() + { + ((Action)(() => SpellAttributes.From(null!))).Should().Throw(); + ((Action)(() => SpellAttributes.DecodeIsInterruptible(null!))).Should().Throw(); + } + + [Fact] + public void Should_ThrowArgumentNull_When_SpellCreateSchoolNull() + { + ((Action)(() => Spell.Create(BlizzardSpellId.From(1), PatchVersion.From(11, 0, 5), + SpellName.From("X"), null!, _at))).Should().Throw(); + } + + [Fact] + public void Should_ThrowArgumentNull_When_WagoCreateRequiredArgNull() + { + var pv = PatchVersion.From(11, 0, 5); + var name = SpellName.From("Kick"); + var attrs = SpellAttributes.Empty(); + var bid = BlizzardSpellId.From(1); + + ((Action)(() => WagoSpellRecord.Create(null!, pv, name, SpellSchoolMask.Physical, SpellMechanic.None, attrs, null, null, _at))).Should().Throw(); + ((Action)(() => WagoSpellRecord.Create(bid, null!, name, SpellSchoolMask.Physical, SpellMechanic.None, attrs, null, null, _at))).Should().Throw(); + ((Action)(() => WagoSpellRecord.Create(bid, pv, null!, SpellSchoolMask.Physical, SpellMechanic.None, attrs, null, null, _at))).Should().Throw(); + ((Action)(() => WagoSpellRecord.Create(bid, pv, name, SpellSchoolMask.Physical, SpellMechanic.None, null!, null, null, _at))).Should().Throw(); + } + + [Fact] + public void Should_CopyInputGroups_When_SpellAttributesFrom() + { + var groups = new long[8]; + var attrs = SpellAttributes.From(groups); + groups[7] = 1L << 11; + + attrs.IsInterruptible.Should().BeFalse(); + } + + [Fact] + public void Should_DecodeInterruptible_AtGroupCountBoundary() + { + var eightWithBit = new long[8]; + eightWithBit[7] = 1L << 11; + SpellAttributes.DecodeIsInterruptible(eightWithBit).Should().BeTrue(); + + var eightWithoutBit = new long[8]; + SpellAttributes.DecodeIsInterruptible(eightWithoutBit).Should().BeFalse(); + + SpellAttributes.DecodeIsInterruptible(new long[7]).Should().BeFalse(); + } + + // ---- WagoSpellRecord FromSnapshot re-validation ---- + + private static WagoSpellRecordSnapshot WagoSnapshot( + int mechanic = (int)SpellMechanic.None, long? durationMs = 1000, long? cooldownMs = 2000) => + new(Guid.NewGuid(), 12345, "11.0.5", "Kick", (byte)SpellSchoolMask.Physical, + mechanic, new long[15], durationMs, cooldownMs, _at); + + [Fact] + public void Should_RoundtripValidSnapshot_When_WagoSpellRecordRehydrated() + { + WagoSpellRecord.FromSnapshot(WagoSnapshot()).BlizzardSpellId.Value.Should().Be(12345); + } + + [Fact] + public void Should_AcceptZeroDurationAndCooldown_When_WagoSpellRecordRehydrated() + { + var record = WagoSpellRecord.FromSnapshot(WagoSnapshot(durationMs: 0, cooldownMs: 0)); + + record.Duration.Should().Be(TimeSpan.Zero); + record.Cooldown.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void Should_WrapValueAndCompare_When_WagoSpellRecordId() + { + ((Action)(() => WagoSpellRecordId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + WagoSpellRecordId.From(guid).Should().Be(WagoSpellRecordId.From(guid)); + WagoSpellRecordId.Create().Should().NotBe(WagoSpellRecordId.Create()); + } + + [Fact] + public void Should_ThrowOnRehydration_When_MechanicUndefined() + { + ((Action)(() => WagoSpellRecord.FromSnapshot(WagoSnapshot(mechanic: 9999)))) + .Should().Throw(); + } + + [Fact] + public void Should_ThrowOnRehydration_When_DurationOrCooldownNegative() + { + ((Action)(() => WagoSpellRecord.FromSnapshot(WagoSnapshot(durationMs: -1)))).Should().Throw(); + ((Action)(() => WagoSpellRecord.FromSnapshot(WagoSnapshot(cooldownMs: -1)))).Should().Throw(); + } + + // ---- Spell aggregate ---- + + private static Spell NewSpell(Provenance? provenance = null) => + Spell.Create( + BlizzardSpellId.From(47528), + PatchVersion.From(11, 0, 5), + SpellName.From("Pummel"), + SpellSchool.Physical, + _at, + baseCooldown: TimeSpan.FromSeconds(15), + isInterruptible: false, + provenance: provenance).Value!; + + [Fact] + public void Should_RejectNegativeCooldown_When_SpellCreate() + { + Spell.Create(BlizzardSpellId.From(1), PatchVersion.From(11, 0, 5), SpellName.From("X"), + SpellSchool.Fire, _at, baseCooldown: TimeSpan.FromSeconds(-1)).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RaiseRegisteredEvent_When_SpellCreated() + { + NewSpell().DomainEvents.OfType().Should().ContainSingle(); + } + + [Fact] + public void Should_DefaultToManualProvenance_When_NoneProvided() + { + NewSpell().Provenance.Source.Should().Be(ProvenanceSource.Manual); + } + + [Fact] + public void Should_KeepProvidedProvenance_When_ExplicitlySupplied() + { + NewSpell(Provenance.Blizzard(_at)).Provenance.Source.Should().Be(ProvenanceSource.BlizzardApi); + } + + [Fact] + public void Should_RejectInvalidTierOrCategory_When_Annotate() + { + NewSpell().Annotate((DangerTier)99, null, [], _at).IsFailure.Should().BeTrue(); + NewSpell().Annotate(null, (DiminishingReturnsCategory)99, [], _at).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_SetAnnotationsDedupeTagsAndRaiseEvent_When_Annotate() + { + var spell = NewSpell(); + + var result = spell.Annotate( + DangerTier.Lethal, DiminishingReturnsCategory.Stun, + [SpellTag.From("aoe-stun"), SpellTag.From("aoe-stun"), SpellTag.From("raid-cd")], + _at); + + ThenResultIsSuccess(result); + spell.DangerTier.Should().Be(DangerTier.Lethal); + spell.DrCategory.Should().Be(DiminishingReturnsCategory.Stun); + spell.Tags.Should().HaveCount(2); + var evt = spell.DomainEvents.OfType().Should().ContainSingle().Subject; + evt.DangerTier.Should().Be((int)DangerTier.Lethal); + evt.DrCategory.Should().Be((int)DiminishingReturnsCategory.Stun); + evt.Tags.Should().BeEquivalentTo("aoe-stun", "raid-cd"); + } + + [Fact] + public void Should_EmitNullTierAndCategory_When_AnnotatedWithoutThem() + { + var spell = NewSpell(); + + spell.Annotate(null, null, [SpellTag.From("kickable-cast")], _at); + + var evt = spell.DomainEvents.OfType().Should().ContainSingle().Subject; + evt.DangerTier.Should().BeNull(); + evt.DrCategory.Should().BeNull(); + } + + [Fact] + public void Should_RefuseSync_When_SpellManuallyOverridden() + { + var spell = NewSpell(Provenance.Manual(_at)); + + var result = spell.SyncFromBlizzard( + SpellName.From("New"), SpellSchool.Fire, TimeSpan.FromSeconds(10), true, Provenance.Blizzard(_at)); + + ThenResultIsFailureWithError(result, SpellKnowledgeErrors.Spell.ManualOverrideProtected); + } + + [Fact] + public void Should_ThrowArgumentNull_When_SyncSchoolNull() + { + var spell = NewSpell(Provenance.Blizzard(_at)); + + ((Action)(() => spell.SyncFromBlizzard(SpellName.From("X"), null!, null, false, Provenance.Blizzard(_at)))) + .Should().Throw(); + } + + [Fact] + public void Should_ResolveKnownAndSyntheticAffixes() + { + AffixCatalog.Resolve(9).Name.Should().Be("Tyrannical"); + AffixCatalog.Resolve(999999).Name.Should().Be("Affix #999999"); + } + + [Fact] + public void Should_RejectNegativeCooldown_When_SyncFromBlizzard() + { + var spell = NewSpell(Provenance.Blizzard(_at)); + + spell.SyncFromBlizzard(SpellName.From("New"), SpellSchool.Fire, TimeSpan.FromSeconds(-1), true, Provenance.Blizzard(_at)) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_UpdateBlizzardFields_When_SyncSucceeds() + { + var spell = NewSpell(Provenance.Blizzard(_at)); + + var result = spell.SyncFromBlizzard( + SpellName.From("Counterspell"), SpellSchool.Arcane, TimeSpan.FromSeconds(24), true, Provenance.Blizzard(_at)); + + ThenResultIsSuccess(result); + spell.Name.Value.Should().Be("Counterspell"); + spell.School.Should().Be(SpellSchool.Arcane); + spell.IsInterruptible.Should().BeTrue(); + spell.BaseCooldown.Should().Be(TimeSpan.FromSeconds(24)); + spell.Provenance.Source.Should().Be(ProvenanceSource.BlizzardApi); + } +} diff --git a/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeValueObjectTests.cs b/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeValueObjectTests.cs new file mode 100644 index 00000000..26e9eaf3 --- /dev/null +++ b/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeValueObjectTests.cs @@ -0,0 +1,179 @@ +namespace SpellKnowledge.Application.UnitTests.Spells.Domain; + +public sealed class SpellKnowledgeValueObjectTests +{ + // ---- PatchVersion ---- + + [Theory] + [InlineData(-1, 0, 0)] + [InlineData(0, -1, 0)] + [InlineData(0, 0, -1)] + public void Should_RejectNegativeComponent_When_PatchVersionFrom(int major, int minor, int build) + { + ((Action)(() => PatchVersion.From(major, minor, build))).Should().Throw(); + } + + [Fact] + public void Should_RejectNegativeRevision_When_PatchVersionFrom() + { + ((Action)(() => PatchVersion.From(11, 0, 5, -1))).Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("11.0")] + [InlineData("11.0.5.6.7")] + [InlineData("11.x.5")] + [InlineData("11.0.5.notnum")] + public void Should_RejectMalformed_When_PatchVersionParse(string raw) + { + ((Action)(() => PatchVersion.Parse(raw))).Should().Throw(); + } + + [Fact] + public void Should_ParseThreePartAndFourPart_When_PatchVersion() + { + PatchVersion.Parse("11.0.5").ToString().Should().Be("11.0.5"); + PatchVersion.Parse("11.0.7.56000").ToString().Should().Be("11.0.7.56000"); + } + + [Fact] + public void Should_DistinguishByEveryComponentIncludingRevision_When_PatchVersionEquality() + { + PatchVersion.From(11, 0, 5).Should().Be(PatchVersion.From(11, 0, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(12, 0, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 1, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 0, 6)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 0, 5, 1)); + PatchVersion.From(11, 0, 5, 1).Should().NotBe(PatchVersion.From(11, 0, 5, 2)); + } + + // ---- SpellAttributes ---- + + [Fact] + public void Should_RejectFewerThanMinGroups_When_SpellAttributesFrom() + { + ((Action)(() => SpellAttributes.From(new long[SpellAttributes.MinAttributeGroupCount - 1]))) + .Should().Throw(); + } + + [Fact] + public void Should_DecodeInterruptible_When_Attr7Bit11Set() + { + var groups = new long[SpellAttributes.DefaultAttributeGroupCount]; + groups[7] = 1L << 11; + + SpellAttributes.From(groups).IsInterruptible.Should().BeTrue(); + SpellAttributes.Empty().IsInterruptible.Should().BeFalse(); + } + + [Fact] + public void Should_DecodeInterruptibleFromRawGroups_When_DecodeIsInterruptible() + { + var groups = new long[15]; + groups[7] = 1L << 11; + + SpellAttributes.DecodeIsInterruptible(groups).Should().BeTrue(); + SpellAttributes.DecodeIsInterruptible(new long[3]).Should().BeFalse(); + } + + [Fact] + public void Should_RejectOutOfRange_When_HasFlagArgsInvalid() + { + var attrs = SpellAttributes.Empty(); + + ((Action)(() => attrs.HasFlag(-1, 0))).Should().Throw(); + ((Action)(() => attrs.HasFlag(99, 0))).Should().Throw(); + ((Action)(() => attrs.HasFlag(7, -1))).Should().Throw(); + ((Action)(() => attrs.HasFlag(7, 32))).Should().Throw(); + } + + [Fact] + public void Should_CompareByGroups_When_SpellAttributesEquality() + { + var a = SpellAttributes.From(new long[8]); + var b = SpellAttributes.From(new long[8]); + var different = new long[8]; + different[7] = 5; + + a.Should().Be(b); + a.Should().NotBe(SpellAttributes.From(different)); + } + + // ---- SpellName ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RejectBlank_When_SpellNameFrom(string value) + { + ((Action)(() => SpellName.From(value))).Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_SpellNameExceedsMax() + { + ((Action)(() => SpellName.From(new string('a', SpellName.MaxLength + 1)))).Should().Throw(); + } + + [Fact] + public void Should_TrimAndCompareByValue_When_SpellName() + { + SpellName.From(" Kick ").Value.Should().Be("Kick"); + SpellName.From("Kick").Should().Be(SpellName.From("Kick")); + SpellName.From("Kick").Should().NotBe(SpellName.From("Pummel")); + } + + // ---- SpellTag ---- + + [Theory] + [InlineData("")] + [InlineData("AoE Stun")] + [InlineData("aoe_stun")] + [InlineData("-aoe")] + public void Should_RejectInvalid_When_SpellTagFrom(string value) + { + ((Action)(() => SpellTag.From(value))).Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_SpellTagExceedsMax() + { + ((Action)(() => SpellTag.From(new string('a', SpellTag.MaxLength + 1)))).Should().Throw(); + } + + [Fact] + public void Should_NormalizeAndCompareByValue_When_SpellTag() + { + SpellTag.From(" AOE-Stun ").Value.Should().Be("aoe-stun"); + SpellTag.From("aoe-stun").Should().Be(SpellTag.From("AOE-STUN")); + SpellTag.From("aoe-stun").Should().NotBe(SpellTag.From("raid-cd")); + } + + // ---- BlizzardSpellId / SpellId ---- + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Should_RejectNonPositive_When_BlizzardSpellIdFrom(int value) + { + ((Action)(() => BlizzardSpellId.From(value))).Should().Throw(); + } + + [Fact] + public void Should_CompareByValue_When_BlizzardSpellId() + { + BlizzardSpellId.From(47528).Value.Should().Be(47528); + BlizzardSpellId.From(47528).Should().Be(BlizzardSpellId.From(47528)); + BlizzardSpellId.From(47528).Should().NotBe(BlizzardSpellId.From(47529)); + } + + [Fact] + public void Should_RejectEmptyGuidAndCompareByValue_When_SpellId() + { + ((Action)(() => SpellId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + SpellId.From(guid).Should().Be(SpellId.From(guid)); + SpellId.Create().Should().NotBe(SpellId.Create()); + } +} diff --git a/stryker-config.audit.json b/stryker-config.audit.json new file mode 100644 index 00000000..456761f2 --- /dev/null +++ b/stryker-config.audit.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-net/master/src/Stryker.Core/Stryker.Core/stryker-schema.json", + "stryker-config": { + "project-info": { + "name": "Wow", + "module": "Wow" + }, + "reporters": [ + "json", + "cleartext" + ], + "verbosity": "info", + "mutate": [ + "**/*.cs", + "!**/obj/**", + "!**/bin/**", + "!**/*Tests*/**", + "!**/*Test*/**", + "!**/Migrations/**", + "!**/AssemblyInfo.cs", + "!**/GlobalUsings.cs", + "!**/Aspire/**", + "!**/WowApi/**", + "!**/WowSso.Api/**", + "!**/WowPve.Api/**", + "!**/Providers/**", + "!**/PveAnalytics/**", + "!**/*.Infrastructure/**", + "!**/Kernel.Shared/**/Mediator/**", + "!**/Kernel.Shared/**/Outbox/**", + "!**/Kernel.Shared/**/Localization/**", + "!**/Kernel.Shared/**/Hateoas/**", + "!**/Kernel.Shared/**/DependencyInjection/**", + "!**/Kernel.Shared/**/UtcDateTimeProvider.cs", + "!**/Errors/*Translations.cs", + "!**/Errors/*Translations.cs" + ], + "ignore-mutations": [ + "string", + "linq" + ], + "ignore-methods": [ + "ToString", + "GetHashCode", + "Equals" + ], + "concurrency": 4, + "thresholds": { + "high": 80, + "low": 60, + "break": 50 + }, + "mutation-level": "Standard" + } +} diff --git a/stryker-config.json b/stryker-config.json index b80e7c2f..02f263c6 100644 --- a/stryker-config.json +++ b/stryker-config.json @@ -28,10 +28,13 @@ "!**/Providers/**", "!**/PveAnalytics/**", "!**/*.Infrastructure/**", - "!**/SharedKernel/Mediator/**", - "!**/SharedKernel/Outbox/**", - "!**/SharedKernel/Localization/**", - "!**/SharedKernel/UtcDateTimeProvider.cs" + "!**/Kernel.Shared/**/Mediator/**", + "!**/Kernel.Shared/**/Outbox/**", + "!**/Kernel.Shared/**/Localization/**", + "!**/Kernel.Shared/**/Hateoas/**", + "!**/Kernel.Shared/**/DependencyInjection/**", + "!**/Kernel.Shared/**/UtcDateTimeProvider.cs", + "!**/Errors/*Translations.cs" ], "ignore-mutations": [ "string",