|
| 1 | +using System; |
| 2 | +using System.Threading.Tasks; |
| 3 | +using Microsoft.EntityFrameworkCore; |
| 4 | +using Microting.eForm.Infrastructure.Constants; |
| 5 | +using Microting.TimePlanningBase.Infrastructure.Data; |
| 6 | +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; |
| 7 | +using Microting.TimePlanningBase.Infrastructure.Data.Entities; |
| 8 | +using NUnit.Framework; |
| 9 | +using TimePlanning.Pn.Infrastructure.Helpers; |
| 10 | + |
| 11 | +namespace TimePlanning.Pn.Test; |
| 12 | + |
| 13 | +[TestFixture] |
| 14 | +public class CorruptedPauseIdRepairTests : TestBaseSetup |
| 15 | +{ |
| 16 | + [SetUp] |
| 17 | + public async Task SetUp() |
| 18 | + { |
| 19 | + await base.Setup(); |
| 20 | + } |
| 21 | + |
| 22 | + [TearDown] |
| 23 | + public new async Task TearDown() |
| 24 | + { |
| 25 | + await base.TearDown(); |
| 26 | + } |
| 27 | + |
| 28 | + // ---- B0: characterize the timestamp-preferring netto writer ---------- |
| 29 | + |
| 30 | + /// <summary> |
| 31 | + /// ComputeNettoSecondsFromDateTimeShifts prefers the pause DateTime delta |
| 32 | + /// and ignores a corrupt absolute-tick Pause1Id. This is the writer used by |
| 33 | + /// ApplyNettoFlexChainSecondPrecision (the flag-on / second-precision path). |
| 34 | + /// </summary> |
| 35 | + [Test] |
| 36 | + public void ComputeNetto_PrefersPauseTimestamps_OverCorruptPauseId() |
| 37 | + { |
| 38 | + var date = new DateTime(2026, 6, 10, 0, 0, 0); |
| 39 | + var pr = new PlanRegistration |
| 40 | + { |
| 41 | + Date = date, |
| 42 | + Start1StartedAt = date.AddHours(8), |
| 43 | + Stop1StoppedAt = date.AddHours(16), // 8h work |
| 44 | + Pause1StartedAt = date.AddHours(12), |
| 45 | + Pause1StoppedAt = date.AddHours(12).AddMinutes(30), // 30m real pause |
| 46 | + Pause1Id = 145, // CORRUPT absolute tick (12:00) |
| 47 | + }; |
| 48 | + |
| 49 | + var netto = PlanRegistrationHelper.ComputeNettoSecondsFromDateTimeShifts(pr); |
| 50 | + |
| 51 | + // 8h - 30m = 7h30m = 27000s, derived from timestamps not the id. |
| 52 | + Assert.That(netto, Is.EqualTo(27000)); |
| 53 | + } |
| 54 | + |
| 55 | + // ---- B1 + B4: repair fixes only in-window 5-min corrupted rows -------- |
| 56 | + |
| 57 | + [Test] |
| 58 | + public async Task Repair_FixesOnlyInWindow5MinCorruptedRows() |
| 59 | + { |
| 60 | + var ctx = TimePlanningPnDbContext!; |
| 61 | + var today = DateTime.UtcNow.Date; |
| 62 | + |
| 63 | + // 5-min site (UseOneMinuteIntervals=false) and a 1-min site. |
| 64 | + var fiveMinSite = await SeedAssignedSite(ctx, siteId: 100, useOneMinute: false); |
| 65 | + var oneMinSite = await SeedAssignedSite(ctx, siteId: 200, useOneMinute: true); |
| 66 | + |
| 67 | + // (a) corrupted in-window: 30m real pause, Pause1Id=145 (absolute 12:00 tick). |
| 68 | + var corrupted = await SeedRow(ctx, fiveMinSite.SiteId, today.AddDays(-1), |
| 69 | + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); |
| 70 | + // (b) correct in-window: 30m pause, Pause1Id=7 ((30/5)+1). |
| 71 | + var correct = await SeedRow(ctx, fiveMinSite.SiteId, today.AddDays(-2), |
| 72 | + pauseStart: 12, pauseStopMin: 30, pause1Id: 7, work: (8, 16)); |
| 73 | + // (c) off-by-one in-window: Pause1Id=6 (min/5, missing +1) -> must be left alone. |
| 74 | + var offByOne = await SeedRow(ctx, fiveMinSite.SiteId, today.AddDays(-3), |
| 75 | + pauseStart: 12, pauseStopMin: 30, pause1Id: 6, work: (8, 16)); |
| 76 | + // (d) corrupted but out of window (> 7 days). |
| 77 | + var oldRow = await SeedRow(ctx, fiveMinSite.SiteId, today.AddDays(-9), |
| 78 | + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); |
| 79 | + // (e) 1-min site, raw-minute id (30) -> not in scope. |
| 80 | + var oneMin = await SeedRow(ctx, oneMinSite.SiteId, today.AddDays(-1), |
| 81 | + pauseStart: 12, pauseStopMin: 30, pause1Id: 30, work: (8, 16)); |
| 82 | + |
| 83 | + await CorruptedPauseIdRepair.Run(ctx); |
| 84 | + |
| 85 | + Assert.That((await Reload(ctx, corrupted)).Pause1Id, Is.EqualTo(7)); // fixed |
| 86 | + Assert.That((await Reload(ctx, correct)).Pause1Id, Is.EqualTo(7)); // unchanged |
| 87 | + Assert.That((await Reload(ctx, offByOne)).Pause1Id, Is.EqualTo(6)); // left alone |
| 88 | + Assert.That((await Reload(ctx, oldRow)).Pause1Id, Is.EqualTo(145)); // out of window |
| 89 | + Assert.That((await Reload(ctx, oneMin)).Pause1Id, Is.EqualTo(30)); // 1-min site |
| 90 | + |
| 91 | + // B4: persisted netto for the repaired row is the timestamp-derived value |
| 92 | + // (8h work - 30m pause = 27000s), not the corrupt tick-derived netto. |
| 93 | + Assert.That((await Reload(ctx, corrupted)).NettoHoursInSeconds, Is.EqualTo(27000)); |
| 94 | + } |
| 95 | + |
| 96 | + // ---- B3: idempotency --------------------------------------------------- |
| 97 | + |
| 98 | + [Test] |
| 99 | + public async Task Repair_IsIdempotent() |
| 100 | + { |
| 101 | + var ctx = TimePlanningPnDbContext!; |
| 102 | + var site = await SeedAssignedSite(ctx, siteId: 100, useOneMinute: false); |
| 103 | + var row = await SeedRow(ctx, site.SiteId, DateTime.UtcNow.Date.AddDays(-1), |
| 104 | + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16)); |
| 105 | + |
| 106 | + await CorruptedPauseIdRepair.Run(ctx); |
| 107 | + var afterFirst = (await Reload(ctx, row)).Pause1Id; |
| 108 | + await CorruptedPauseIdRepair.Run(ctx); |
| 109 | + var afterSecond = (await Reload(ctx, row)).Pause1Id; |
| 110 | + |
| 111 | + Assert.That(afterFirst, Is.EqualTo(7)); |
| 112 | + Assert.That(afterSecond, Is.EqualTo(7)); |
| 113 | + } |
| 114 | + |
| 115 | + // ---- anomaly path never writes ---------------------------------------- |
| 116 | + |
| 117 | + /// <summary> |
| 118 | + /// A corrupt absolute-tick Pause1Id with NO usable pause timestamps is an |
| 119 | + /// unrepairable anomaly: the repair has no oracle to derive the true |
| 120 | + /// duration from, so it must NOT guess/write. The id is left untouched |
| 121 | + /// (the Sentry warning side-effect can't be asserted in this harness). |
| 122 | + /// </summary> |
| 123 | + [Test] |
| 124 | + public async Task Repair_DoesNotWrite_WhenCorruptIdHasNoTimestamps() |
| 125 | + { |
| 126 | + var ctx = TimePlanningPnDbContext!; |
| 127 | + var site = await SeedAssignedSite(ctx, siteId: 100, useOneMinute: false); |
| 128 | + |
| 129 | + // In-window 5-min row: corrupt absolute tick (145) but no pause |
| 130 | + // timestamps to repair from; shift span is a normal 08:00-16:00. |
| 131 | + var row = await SeedRow(ctx, site.SiteId, DateTime.UtcNow.Date.AddDays(-1), |
| 132 | + pauseStart: 12, pauseStopMin: 30, pause1Id: 145, work: (8, 16), |
| 133 | + seedPauseTimestamps: false); |
| 134 | + |
| 135 | + await CorruptedPauseIdRepair.Run(ctx); |
| 136 | + |
| 137 | + Assert.That((await Reload(ctx, row)).Pause1Id, Is.EqualTo(145)); // untouched |
| 138 | + } |
| 139 | + |
| 140 | + // ---- helpers ----------------------------------------------------------- |
| 141 | + |
| 142 | + private static async Task<AssignedSiteEntity> SeedAssignedSite( |
| 143 | + TimePlanningPnDbContext ctx, int siteId, bool useOneMinute) |
| 144 | + { |
| 145 | + var site = new AssignedSiteEntity |
| 146 | + { |
| 147 | + SiteId = siteId, |
| 148 | + UseOneMinuteIntervals = useOneMinute, |
| 149 | + CreatedByUserId = 1, |
| 150 | + UpdatedByUserId = 1 |
| 151 | + }; |
| 152 | + await site.Create(ctx); |
| 153 | + return site; |
| 154 | + } |
| 155 | + |
| 156 | + private static async Task<PlanRegistration> SeedRow( |
| 157 | + TimePlanningPnDbContext ctx, int sdkSitId, DateTime date, |
| 158 | + int pauseStart, int pauseStopMin, int pause1Id, (int Start, int Stop) work, |
| 159 | + bool seedPauseTimestamps = true) |
| 160 | + { |
| 161 | + var pr = new PlanRegistration |
| 162 | + { |
| 163 | + Date = date, |
| 164 | + SdkSitId = sdkSitId, |
| 165 | + Start1StartedAt = date.AddHours(work.Start), |
| 166 | + Stop1StoppedAt = date.AddHours(work.Stop), |
| 167 | + Pause1StartedAt = seedPauseTimestamps ? date.AddHours(pauseStart) : (DateTime?)null, |
| 168 | + Pause1StoppedAt = seedPauseTimestamps ? date.AddHours(pauseStart).AddMinutes(pauseStopMin) : (DateTime?)null, |
| 169 | + Pause1Id = pause1Id, |
| 170 | + CreatedByUserId = 1, |
| 171 | + UpdatedByUserId = 1 |
| 172 | + }; |
| 173 | + await pr.Create(ctx); |
| 174 | + return pr; |
| 175 | + } |
| 176 | + |
| 177 | + private static async Task<PlanRegistration> Reload( |
| 178 | + TimePlanningPnDbContext ctx, PlanRegistration row) |
| 179 | + { |
| 180 | + return await ctx.PlanRegistrations |
| 181 | + .AsNoTracking() |
| 182 | + .FirstAsync(x => x.Id == row.Id); |
| 183 | + } |
| 184 | +} |
0 commit comments