Skip to content

Commit 2a960d9

Browse files
renemadsenclaude
andauthored
fix(timeplanning): self-heal corrupt pauseNId on save (temporary guard) (#1609)
* feat(timeplanning): shared PauseIdCorrection detect+correct helper + unit tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(timeplanning): CorruptedPauseIdRepair uses shared PauseIdCorrection Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(timeplanning): add PauseIdSelfHealEnabled settings flag (default on) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(timeplanning): self-heal corrupt pauseNId on UpdateWorkingHour save (5-min sites) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(timeplanning): on-save pauseNId self-heal guard (kiosk path, flag, 1-min) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(timeplanning): simplify pauseNId self-heal guard - FixSlot: compute actualMinutes once up front instead of duplicating the (stop - start).TotalMinutes expression and the (double?)null branch across the corrected/uncorrected paths. Behavior byte-identical: the helper only returns a non-null correction when timestamps are valid and ordered, so the single up-front value matches both prior branches. - SelfHealCorruptPauseIds: fully-qualify the AssignedSite? parameter to the EF entity type for explicitness, since two same-named AssignedSite types are imported unqualified in this file. No change to the detection threshold, corrected-value math, 5-min/flag gating, call-site placement, or any test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(test): add missing using System.Linq in PauseIdSelfHealGuardTests --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 58a2c70 commit 2a960d9

6 files changed

Lines changed: 422 additions & 29 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System;
2+
using NUnit.Framework;
3+
using TimePlanning.Pn.Infrastructure.Helpers;
4+
5+
namespace TimePlanning.Pn.Test;
6+
7+
[TestFixture]
8+
public class PauseIdCorrectionTests
9+
{
10+
private static readonly DateTime Start = new(2026, 6, 16, 9, 0, 0);
11+
12+
[Test]
13+
public void Corrupt_AbsoluteTick_IsCorrectedToDuration()
14+
{
15+
// 28-minute real pause, but id=116 (absolute 09:35 tick) decodes to 575 min.
16+
var stop = Start.AddMinutes(28);
17+
Assert.That(PauseIdCorrection.CorrectedPauseId(Start, stop, 116), Is.EqualTo(6));
18+
}
19+
20+
[Test]
21+
public void CorrectValue_ReturnsNull()
22+
{
23+
// 30-min pause, id=7 = (30/5)+1 is already correct.
24+
var stop = Start.AddMinutes(30);
25+
Assert.That(PauseIdCorrection.CorrectedPauseId(Start, stop, 7), Is.Null);
26+
}
27+
28+
[Test]
29+
public void OffByOne_IsLeftAlone()
30+
{
31+
// 30-min pause, id=6 = 30/5 (missing +1) understates -> not corrected.
32+
var stop = Start.AddMinutes(30);
33+
Assert.That(PauseIdCorrection.CorrectedPauseId(Start, stop, 6), Is.Null);
34+
}
35+
36+
[Test]
37+
public void ZeroOrMissing_ReturnsNull()
38+
{
39+
var stop = Start.AddMinutes(30);
40+
Assert.That(PauseIdCorrection.CorrectedPauseId(Start, stop, 0), Is.Null);
41+
Assert.That(PauseIdCorrection.CorrectedPauseId(null, stop, 116), Is.Null);
42+
Assert.That(PauseIdCorrection.CorrectedPauseId(Start, null, 116), Is.Null);
43+
Assert.That(PauseIdCorrection.CorrectedPauseId(Start, Start, 116), Is.Null);
44+
}
45+
}
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
using System;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.Extensions.Logging;
6+
using Microting.eForm.Infrastructure.Constants;
7+
using Microting.eFormApi.BasePn.Abstractions;
8+
using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions;
9+
using NSubstitute;
10+
using NUnit.Framework;
11+
using TimePlanning.Pn.Infrastructure.Models.Settings;
12+
using TimePlanning.Pn.Infrastructure.Models.WorkingHours.UpdateCreate;
13+
using TimePlanning.Pn.Services.TimePlanningLocalizationService;
14+
using TimePlanning.Pn.Services.TimePlanningWorkingHoursService;
15+
using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite;
16+
using RegistrationDeviceEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.RegistrationDevice;
17+
18+
namespace TimePlanning.Pn.Test;
19+
20+
/// <summary>
21+
/// Integration coverage for the on-save pauseNId self-heal guard
22+
/// (<c>TimePlanningWorkingHoursService.SelfHealCorruptPauseIds</c>) exercised
23+
/// end-to-end through the kiosk gRPC write path
24+
/// (<c>UpdateWorkingHour(int? sdkSiteId, model, token)</c>) against a real
25+
/// Testcontainers MariaDB.
26+
///
27+
/// The kiosk and personal save paths share the identical guard call placed
28+
/// immediately before the inline 5-minute-tick netto math, so the kiosk path is
29+
/// used to drive every scenario: it requires only <c>userService.UserId</c> and
30+
/// the plugin <c>dbContext</c> (no SDK core / JWT), which the integration harness
31+
/// provides. A corrupt <c>Shift1Pause</c> (absolute stop-tick) plus truthful
32+
/// <c>Pause1StartedAt/StoppedAt</c> must be persisted as the timestamp-derived
33+
/// duration id, healing netto in the same save.
34+
/// </summary>
35+
[TestFixture]
36+
public class PauseIdSelfHealGuardTests : TestBaseSetup
37+
{
38+
[SetUp]
39+
public async Task SetUpTest()
40+
{
41+
await base.Setup();
42+
}
43+
44+
private TimePlanningWorkingHoursService BuildService(bool? pauseIdSelfHealEnabled)
45+
{
46+
var userService = Substitute.For<IUserService>();
47+
userService.UserId.Returns(1);
48+
49+
var localizationService = Substitute.For<ITimePlanningLocalizationService>();
50+
localizationService.GetString(Arg.Any<string>()).Returns(x => x[0]?.ToString());
51+
52+
var coreService = Substitute.For<IEFormCoreService>();
53+
var options = Substitute.For<IPluginDbOptions<TimePlanningBaseSettings>>();
54+
options.Value.Returns(new TimePlanningBaseSettings
55+
{
56+
AutoBreakCalculationActive = "0",
57+
DayOfPayment = 20,
58+
GpsEnabled = "0",
59+
SnapshotEnabled = "0",
60+
PauseIdSelfHealEnabled = pauseIdSelfHealEnabled,
61+
});
62+
63+
// The kiosk path only references userService.UserId and dbContext from the
64+
// constructor graph; baseDbContext / coreHelper are never dereferenced here.
65+
return new TimePlanningWorkingHoursService(
66+
Substitute.For<ILogger<TimePlanningWorkingHoursService>>(),
67+
TimePlanningPnDbContext!,
68+
userService,
69+
localizationService,
70+
baseDbContext: null!,
71+
options,
72+
coreService);
73+
}
74+
75+
private async Task SeedSiteAsync(int siteId, bool useOneMinuteIntervals)
76+
{
77+
await new AssignedSiteEntity
78+
{
79+
SiteId = siteId,
80+
UseOneMinuteIntervals = useOneMinuteIntervals,
81+
WorkflowState = Constants.WorkflowStates.Created,
82+
CreatedByUserId = 1,
83+
UpdatedByUserId = 1,
84+
}.Create(TimePlanningPnDbContext!);
85+
}
86+
87+
private async Task SeedDeviceAsync(string token, string otp)
88+
{
89+
await new RegistrationDeviceEntity
90+
{
91+
Token = token,
92+
Name = "Kiosk Device",
93+
OtpCode = otp,
94+
SoftwareVersion = "1.0.0",
95+
Manufacturer = "Test",
96+
Model = "Test",
97+
OsVersion = "1.0",
98+
WorkflowState = Constants.WorkflowStates.Created,
99+
CreatedByUserId = 1,
100+
UpdatedByUserId = 1,
101+
}.Create(TimePlanningPnDbContext!);
102+
}
103+
104+
// A worked shift of 96 5-minute ticks (108 -> 204, i.e. 09:00 -> 17:00).
105+
// The inline netto math is (Stop1Id - Start1Id) - (Pause1Id - 1), then * 5.
106+
// With the corrupt id (116) netto is grossly negative; with the corrected id
107+
// (6) netto is sane and positive.
108+
private static TimePlanningWorkingHoursUpdateModel BuildModel(
109+
DateTime date, int pauseId, int pauseMinutes)
110+
{
111+
var pauseStart = date.AddHours(12);
112+
var pauseStop = pauseStart.AddMinutes(pauseMinutes);
113+
return new TimePlanningWorkingHoursUpdateModel
114+
{
115+
Date = date,
116+
Shift1Start = 108,
117+
Shift1Stop = 204,
118+
Shift1Pause = pauseId,
119+
Start1StartedAt = $"{date:yyyy-MM-dd}T09:00:00",
120+
Stop1StoppedAt = $"{date:yyyy-MM-dd}T17:00:00",
121+
Pause1StartedAt = pauseStart.ToString("yyyy-MM-ddTHH:mm:ss"),
122+
Pause1StoppedAt = pauseStop.ToString("yyyy-MM-ddTHH:mm:ss"),
123+
CommentWorker = "",
124+
OsVersion = "1.0",
125+
Model = "Test",
126+
Manufacturer = "Test",
127+
SoftwareVersion = "1.0.0",
128+
};
129+
}
130+
131+
private async Task<Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration>
132+
PersistedRowAsync(int siteId, DateTime date)
133+
{
134+
return await TimePlanningPnDbContext!.PlanRegistrations
135+
.AsNoTracking()
136+
.Where(x => x.SdkSitId == siteId && x.Date == date)
137+
.OrderByDescending(x => x.Id)
138+
.FirstAsync();
139+
}
140+
141+
[Test]
142+
public async Task Corrupt_5MinSite_IsHealedOnSave()
143+
{
144+
const int siteId = 9601;
145+
var token = "selfheal-corrupt";
146+
var date = new DateTime(2026, 6, 16, 0, 0, 0);
147+
await SeedSiteAsync(siteId, useOneMinuteIntervals: false);
148+
await SeedDeviceAsync(token, "10001");
149+
150+
// 28-min real pause, corrupt absolute-tick id 116 -> corrected to 6.
151+
var model = BuildModel(date, pauseId: 116, pauseMinutes: 28);
152+
var service = BuildService(pauseIdSelfHealEnabled: null);
153+
154+
var result = await service.UpdateWorkingHour(sdkSiteId: siteId, model, token);
155+
Assert.That(result.Success, Is.True, result.Message);
156+
157+
var pr = await PersistedRowAsync(siteId, date);
158+
Assert.Multiple(() =>
159+
{
160+
Assert.That(pr.Pause1Id, Is.EqualTo(6), "corrupt absolute-tick id must be healed to (28/5)+1");
161+
Assert.That(pr.NettoHours, Is.GreaterThan(0), "netto must be sane (positive) after healing");
162+
});
163+
}
164+
165+
[Test]
166+
public async Task Correct_Value_Unchanged()
167+
{
168+
const int siteId = 9602;
169+
var token = "selfheal-correct";
170+
var date = new DateTime(2026, 6, 16, 0, 0, 0);
171+
await SeedSiteAsync(siteId, useOneMinuteIntervals: false);
172+
await SeedDeviceAsync(token, "10002");
173+
174+
// 30-min pause, already-correct id 7 = (30/5)+1.
175+
var model = BuildModel(date, pauseId: 7, pauseMinutes: 30);
176+
var service = BuildService(pauseIdSelfHealEnabled: null);
177+
178+
var result = await service.UpdateWorkingHour(sdkSiteId: siteId, model, token);
179+
Assert.That(result.Success, Is.True, result.Message);
180+
181+
var pr = await PersistedRowAsync(siteId, date);
182+
Assert.That(pr.Pause1Id, Is.EqualTo(7), "correct id must be left unchanged");
183+
}
184+
185+
[Test]
186+
public async Task OffByOne_Unchanged()
187+
{
188+
const int siteId = 9603;
189+
var token = "selfheal-offbyone";
190+
var date = new DateTime(2026, 6, 16, 0, 0, 0);
191+
await SeedSiteAsync(siteId, useOneMinuteIntervals: false);
192+
await SeedDeviceAsync(token, "10003");
193+
194+
// 30-min pause, id 6 = 30/5 (missing +1) understates -> not corrected.
195+
var model = BuildModel(date, pauseId: 6, pauseMinutes: 30);
196+
var service = BuildService(pauseIdSelfHealEnabled: null);
197+
198+
var result = await service.UpdateWorkingHour(sdkSiteId: siteId, model, token);
199+
Assert.That(result.Success, Is.True, result.Message);
200+
201+
var pr = await PersistedRowAsync(siteId, date);
202+
Assert.That(pr.Pause1Id, Is.EqualTo(6), "off-by-one must be left unchanged");
203+
}
204+
205+
[Test]
206+
public async Task OneMinuteSite_Unchanged()
207+
{
208+
const int siteId = 9604;
209+
var token = "selfheal-1min";
210+
var date = new DateTime(2026, 6, 16, 0, 0, 0);
211+
await SeedSiteAsync(siteId, useOneMinuteIntervals: true);
212+
await SeedDeviceAsync(token, "10004");
213+
214+
// 1-minute sites are never affected; the guard must skip them.
215+
var model = BuildModel(date, pauseId: 116, pauseMinutes: 28);
216+
var service = BuildService(pauseIdSelfHealEnabled: null);
217+
218+
var result = await service.UpdateWorkingHour(sdkSiteId: siteId, model, token);
219+
Assert.That(result.Success, Is.True, result.Message);
220+
221+
var pr = await PersistedRowAsync(siteId, date);
222+
Assert.That(pr.Pause1Id, Is.EqualTo(116), "guard must skip 1-minute sites");
223+
}
224+
225+
[Test]
226+
public async Task FlagDisabled_NotHealed()
227+
{
228+
const int siteId = 9605;
229+
var token = "selfheal-flagoff";
230+
var date = new DateTime(2026, 6, 16, 0, 0, 0);
231+
await SeedSiteAsync(siteId, useOneMinuteIntervals: false);
232+
await SeedDeviceAsync(token, "10005");
233+
234+
var model = BuildModel(date, pauseId: 116, pauseMinutes: 28);
235+
// Kill switch off -> the corrupt id must be persisted as-is.
236+
var service = BuildService(pauseIdSelfHealEnabled: false);
237+
238+
var result = await service.UpdateWorkingHour(sdkSiteId: siteId, model, token);
239+
Assert.That(result.Success, Is.True, result.Message);
240+
241+
var pr = await PersistedRowAsync(siteId, date);
242+
Assert.That(pr.Pause1Id, Is.EqualTo(116), "flag off -> guard must not heal");
243+
}
244+
245+
[Test]
246+
public async Task Kiosk_Path_IsHealed()
247+
{
248+
const int siteId = 9606;
249+
var token = "selfheal-kiosk";
250+
var date = new DateTime(2026, 6, 17, 0, 0, 0);
251+
await SeedSiteAsync(siteId, useOneMinuteIntervals: false);
252+
await SeedDeviceAsync(token, "10006");
253+
254+
// Explicit kiosk-path heal of a corrupt absolute-tick id.
255+
var model = BuildModel(date, pauseId: 116, pauseMinutes: 28);
256+
var service = BuildService(pauseIdSelfHealEnabled: null);
257+
258+
var result = await service.UpdateWorkingHour(sdkSiteId: siteId, model, token);
259+
Assert.That(result.Success, Is.True, result.Message);
260+
261+
var pr = await PersistedRowAsync(siteId, date);
262+
Assert.Multiple(() =>
263+
{
264+
Assert.That(pr.Pause1Id, Is.EqualTo(6), "kiosk save must heal the corrupt id");
265+
Assert.That(pr.NettoHours, Is.GreaterThan(0), "kiosk netto must be sane after healing");
266+
});
267+
}
268+
}

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,6 @@ namespace TimePlanning.Pn.Infrastructure.Helpers;
4545
/// </summary>
4646
public static class CorruptedPauseIdRepair
4747
{
48-
// Minutes encoded per pauseNId tick on 5-minute sites; the server contract
49-
// encodes/decodes pause duration as (minutes / 5) + 1.
50-
private const int MinutesPerTick = 5;
51-
52-
// A pause longer than the real one by more than this many minutes is
53-
// treated as the absolute-tick corruption. The 5-minute off-by-one
54-
// (5 min short) never trips this.
55-
private const int CorruptionToleranceMinutes = 15;
56-
5748
/// <summary>
5849
/// Fallback "implausibly large" span used by the anomaly heuristic when the
5950
/// shift-1 worked span is unknown. 12h (720 min).
@@ -184,7 +175,7 @@ private static bool IsUnrepairableAnomaly(SlotResult result, double? workedMinut
184175
// Only the rows we could NOT repair from timestamps qualify.
185176
if (result.ActualMinutes is not null) return false;
186177

187-
var decodedMinutes = (result.OldValue - 1) * MinutesPerTick;
178+
var decodedMinutes = (result.OldValue - 1) * PauseIdCorrection.MinutesPerTick;
188179
var implausibleSpan = workedMinutes ?? UnknownSpanFallbackMinutes;
189180
return decodedMinutes > implausibleSpan;
190181
}
@@ -195,26 +186,26 @@ private static bool IsUnrepairableAnomaly(SlotResult result, double? workedMinut
195186
// the pre-logging version.
196187
private static SlotResult FixSlot(DateTime? start, DateTime? stop, int currentId, Action<int> assign)
197188
{
198-
if (currentId <= 0 || start is null || stop is null || stop <= start)
199-
return new SlotResult { Corrected = false, OldValue = currentId, ActualMinutes = null };
200-
201-
var actualMinutes = (stop.Value - start.Value).TotalMinutes;
202-
var decodedMinutes = (currentId - 1) * MinutesPerTick;
203-
204-
// Only repair the clear absolute-tick overstatement; ignore the
205-
// known 5-minute off-by-one (out of scope).
206-
if (decodedMinutes - actualMinutes <= CorruptionToleranceMinutes)
189+
// actualMinutes drives the anomaly heuristic / logging; it is only
190+
// meaningful when the timestamps exist and are ordered, otherwise null.
191+
double? actualMinutes = (start.HasValue && stop.HasValue && stop.Value > start.Value)
192+
? (stop.Value - start.Value).TotalMinutes
193+
: null;
194+
195+
// Delegate the detect+correct decision to the shared single-source-of-truth
196+
// helper, so the batch repair and the on-save guard apply byte-identical
197+
// rules. The corrected value and the >15 min tolerance are unchanged.
198+
var corrected = PauseIdCorrection.CorrectedPauseId(start, stop, currentId);
199+
if (corrected is null)
207200
return new SlotResult { Corrected = false, OldValue = currentId, ActualMinutes = actualMinutes };
208201

209-
// Server contract: (durationMinutes / 5) + 1, matching the existing
210-
// server "/5 + 1" encode (integer/truncating). actualMinutes is a
211-
// double, so dividing by the int MinutesPerTick performs the same
212-
// double division as the previous / 5.0 before the truncating cast.
213-
var corrected = (int)(actualMinutes / MinutesPerTick) + 1;
214-
if (corrected == currentId)
215-
return new SlotResult { Corrected = false, OldValue = currentId, ActualMinutes = actualMinutes };
216-
217-
assign(corrected);
218-
return new SlotResult { Corrected = true, OldValue = currentId, NewValue = corrected, ActualMinutes = actualMinutes };
202+
assign(corrected.Value);
203+
return new SlotResult
204+
{
205+
Corrected = true,
206+
OldValue = currentId,
207+
NewValue = corrected.Value,
208+
ActualMinutes = actualMinutes,
209+
};
219210
}
220211
}

0 commit comments

Comments
 (0)