Skip to content

Commit 2533bf6

Browse files
CryptoJonesAaron K. Clarkclaude
authored
feat(timeentry): reject single-bound PATCH inversion at the controller layer (#130 follow-up) (#156)
#130 added a zod `.refine()` on createTimeEntryBody and updateTimeEntryBody that rejects teEndedAt < teStartedAt when both bounds are in the request body. The PR called out the single-bound PATCH case as intentionally NOT covered there: a PATCH that supplies only teEndedAt (or only teStartedAt) merges against the row's existing value, which the schema layer can't see without a DB read. Said: > The single-bound PATCH case (only teEndedAt, validated against > the row's existing teStartedAt) can't be enforced at the schema > layer without a DB read. Leave that on the controller layer > for a follow-up. This is that follow-up. After the controller computes `mergedStart` / `mergedEnd` from the body + existing row, check whether the merged range is inverted; reject with 400 instead of silently dropping `teMinutes` to null (the previous behaviour, which the PR review confirmed had real-world bookkeeping cost: "clocked out before clocked in" rows look correct in their two timestamp columns but have an empty duration). Factored the check into a small `isInvertedRange(startedAt, endedAt)` helper, exposed via `_internals` for unit testing. Equality (zero-minute "Due on Receipt"-style entries) stays allowed. Unparseable input returns false from the helper because computeMinutes' own NaN guard already null-tags those rows; flagging them at this layer too would be double-counting a problem we'd 400 on elsewhere. Four new unit tests on the helper cover the four classes: missing-bound (false), happy-path including equality (false), inverted (true), unparseable (false). Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 53b408a commit 2533bf6

2 files changed

Lines changed: 91 additions & 5 deletions

File tree

app/controllers/timeentrycontroller.js

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,29 @@ function computeMinutes(startedAt, endedAt) {
3030
return Math.round((end - start) / 60000);
3131
}
3232

33+
/**
34+
* Return true when both bounds are non-empty AND parse cleanly AND
35+
* endedAt is strictly before startedAt. Used by the PATCH handler to
36+
* reject single-bound updates whose merged interval would invert.
37+
*
38+
* The schema-layer refinement on updateTimeEntryBody (#130) catches
39+
* the both-bounds-in-body case; this helper covers the half the
40+
* schema can't see — a PATCH that supplies only one of the two
41+
* bounds and merges against the row's existing value.
42+
*
43+
* Equality is NOT inverted (zero-minute entries are legitimate).
44+
* Unparseable input is NOT inverted (computeMinutes will surface
45+
* the null-result via its own NaN guard; flagging it here would
46+
* be a false positive on garbage we'd 400 elsewhere anyway).
47+
*/
48+
function isInvertedRange(startedAt, endedAt) {
49+
if (!startedAt || !endedAt) return false;
50+
const start = new Date(startedAt).getTime();
51+
const end = new Date(endedAt).getTime();
52+
if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
53+
return end < start;
54+
}
55+
3356
/**
3457
* POST /v1/timeentry
3558
*
@@ -236,10 +259,25 @@ exports.update = async (req, res) => {
236259
}
237260
// Recompute minutes if either bound changed.
238261
if (updates.teStartedAt !== undefined || updates.teEndedAt !== undefined) {
239-
updates.teMinutes = computeMinutes(
240-
updates.teStartedAt !== undefined ? updates.teStartedAt : entry.teStartedAt,
241-
updates.teEndedAt !== undefined ? updates.teEndedAt : entry.teEndedAt,
242-
);
262+
const mergedStart = updates.teStartedAt !== undefined
263+
? updates.teStartedAt : entry.teStartedAt;
264+
const mergedEnd = updates.teEndedAt !== undefined
265+
? updates.teEndedAt : entry.teEndedAt;
266+
// Inverted-range guard for the single-bound PATCH case. The
267+
// schema-layer refinement in updateTimeEntryBody (#130) only
268+
// sees fields present in the request body, so a PATCH that
269+
// supplies only one bound can't be validated there — the
270+
// row's existing value lives in the DB. Reject merged
271+
// end < start at 400 instead of silently dropping `teMinutes`
272+
// to null and storing a row whose clocked-out-before-clocked-
273+
// in timestamps look correct but whose duration column is
274+
// blank.
275+
if (isInvertedRange(mergedStart, mergedEnd)) {
276+
return res.status(400).json({
277+
message: 'teEndedAt must be at or after teStartedAt.',
278+
});
279+
}
280+
updates.teMinutes = computeMinutes(mergedStart, mergedEnd);
243281
}
244282
try {
245283
await entry.update(updates);
@@ -414,4 +452,4 @@ exports.exportCsv = async (req, res) => {
414452
};
415453

416454
// Exposed for unit testing.
417-
exports._internals = { computeMinutes, IsMaster, GetCompanyId };
455+
exports._internals = { computeMinutes, isInvertedRange, IsMaster, GetCompanyId };

tests/api/timeentry.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,54 @@ describe('PATCH /v1/timeentry/:id auth contract', () => {
187187
});
188188
});
189189

190+
describe('isInvertedRange helper', () => {
191+
// The PATCH /v1/timeentry/:id handler uses this to reject the
192+
// single-bound update case where the schema-layer refinement
193+
// can't see the row's existing other bound. Returning true means
194+
// "reject with 400"; false means "let it through".
195+
//
196+
// Unit-tested in isolation rather than via supertest because the
197+
// full PATCH path requires auth + DB mocks that don't compose
198+
// cleanly with vitest's per-file vi.mock model for this codebase.
199+
// The controller is a 1-line `if (isInvertedRange(a, b)) return
200+
// res.status(400)...`, so a unit test on the helper is the
201+
// tightest coverage.
202+
let isInvertedRange;
203+
beforeAll(async () => {
204+
const ctrl = await import('../../app/controllers/timeentrycontroller.js');
205+
isInvertedRange = ctrl._internals.isInvertedRange;
206+
});
207+
208+
test('false when either bound is missing (open-ended entry, legitimate)', () => {
209+
expect(isInvertedRange(null, '2026-05-15T10:00:00Z')).toBe(false);
210+
expect(isInvertedRange('2026-05-15T10:00:00Z', null)).toBe(false);
211+
expect(isInvertedRange(null, null)).toBe(false);
212+
expect(isInvertedRange(undefined, undefined)).toBe(false);
213+
});
214+
215+
test('false when ended >= started (the happy paths)', () => {
216+
expect(isInvertedRange('2026-05-15T09:00:00Z', '2026-05-15T10:00:00Z')).toBe(false);
217+
// Equality: zero-minute entry is legitimate.
218+
expect(isInvertedRange('2026-05-15T09:00:00Z', '2026-05-15T09:00:00Z')).toBe(false);
219+
});
220+
221+
test('true when ended < started (the bug we reject at 400)', () => {
222+
expect(isInvertedRange('2026-05-15T10:00:00Z', '2026-05-15T09:00:00Z')).toBe(true);
223+
// Even one millisecond difference flips the bit.
224+
expect(isInvertedRange('2026-05-15T10:00:00.001Z', '2026-05-15T10:00:00.000Z')).toBe(true);
225+
});
226+
227+
test('false on unparseable input (computeMinutes guards null elsewhere)', () => {
228+
// The helper deliberately does NOT 400 on garbage strings — those
229+
// get null teMinutes via computeMinutes' NaN guard. Treating them
230+
// as "inverted" here would be a false positive flagging input
231+
// that's already otherwise-broken.
232+
expect(isInvertedRange('not-a-date', '2026-05-15T10:00:00Z')).toBe(false);
233+
expect(isInvertedRange('2026-05-15T10:00:00Z', 'also-not-a-date')).toBe(false);
234+
expect(isInvertedRange('garbage', 'garbage')).toBe(false);
235+
});
236+
});
237+
190238
describe('DELETE /v1/timeentry/:id auth contract', () => {
191239
test('returns 403 when authKey header is missing', async () => {
192240
const res = await request(app).delete('/v1/timeentry/1');

0 commit comments

Comments
 (0)