Skip to content

Commit 58a2c70

Browse files
renemadsenclaude
andauthored
fix(timeplanning): startup repair for corrupted pauseNId (last 7 days, 5-min sites) (#1608)
Recomputes pauseNId from the intact pause timestamps for rows whose stored value grossly overstates the real pause (the absolute-stop-tick corruption), idempotently, on plugin startup. Re-establishes persisted netto/flex consistency. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2394e5f commit 58a2c70

3 files changed

Lines changed: 415 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
}

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr
200200

201201
// Seed database
202202
SeedDatabase(connectionString);
203+
204+
// One-shot, idempotent repair of pauseNId corruption (last 7 days,
205+
// 5-minute sites). Safe to run on every startup.
206+
RepairCorruptedPauseIds(connectionString);
203207
}
204208

205209
public void Configure(IApplicationBuilder appBuilder)
@@ -908,6 +912,13 @@ public void SeedDatabase(string connectionString)
908912
TimePlanningPluginSeed.SeedData(dbContext);
909913
}
910914

915+
public void RepairCorruptedPauseIds(string connectionString)
916+
{
917+
var contextFactory = new TimePlanningPnContextFactory();
918+
using var dbContext = contextFactory.CreateDbContext([connectionString]);
919+
CorruptedPauseIdRepair.Run(dbContext).GetAwaiter().GetResult();
920+
}
921+
911922
public PluginPermissionsManager GetPermissionsManager(string connectionString)
912923
{
913924
var contextFactory = new TimePlanningPnContextFactory();

0 commit comments

Comments
 (0)