Files
kandev/apps/web/lib/state/office-task-content-sync.test.ts
T
2026-08-27 20:45:05 +00:00

533 lines
22 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import {
__resetOfficeTaskContentSyncForTests,
beginWrite,
closeFieldEditor,
endAllEditorsForTask,
endWrite,
getCanonicalValue,
isFieldGuarded,
nextTaskSequence,
openFieldEditor,
parseUpdatedAt,
recordRefetchCandidate,
recordWriteSuccess,
seedInitialCanonical,
shouldRestoreAfterFailedWrite,
subscribeField,
} from "./office-task-content-sync";
const TASK = "task-1";
const T10 = "2026-08-20T10:00:00Z";
const T11 = "2026-08-20T11:00:00Z";
const T12 = "2026-08-20T12:00:00Z";
beforeEach(() => {
__resetOfficeTaskContentSyncForTests();
});
describe("parseUpdatedAt", () => {
it("parses the SQLite space-separated offset-bearing layout to nanosecond precision (AC-66)", () => {
const ns = parseUpdatedAt("2026-08-20 14:30:05.123456789+00:00");
const wholeSecondMs = Date.parse("2026-08-20T14:30:05+00:00");
expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
});
it("parses the RFC3339Nano T/Z layout emitted by a write response to nanosecond precision (AC-66)", () => {
const ns = parseUpdatedAt("2026-08-20T14:30:05.123456789Z");
const wholeSecondMs = Date.parse("2026-08-20T14:30:05Z");
expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
});
it("treats the space-separated form's offset as authoritative, matching its T/Z equivalent", () => {
const spaceForm = parseUpdatedAt("2026-08-20 14:30:05.123456789+00:00");
const tForm = parseUpdatedAt("2026-08-20T14:30:05.123456789Z");
expect(spaceForm).toBe(tForm);
});
it("resolves the space-separated form via its own embedded offset, not the host machine's local timezone (AC-66)", () => {
// Every other space-separated fixture above uses a +00:00 offset, which
// coincidentally equals local time on a UTC-TZ test runner and would not
// catch a regression to `new Date(rawSpaceSeparatedString)` — the spec
// explicitly calls out that browsers disagree on how (or whether) to
// parse that non-ISO-8601 form, with some falling back to local time.
// A non-zero offset makes the correct answer distinguishable from any
// such fallback regardless of what timezone the test happens to run in.
const ns = parseUpdatedAt("2026-08-20 14:30:05.123456789+05:30");
// 14:30:05+05:30 is 09:00:05Z — computed via an explicit offset, so this
// expected value is itself timezone-independent.
const wholeSecondMs = Date.parse("2026-08-20T09:00:05+00:00");
expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
});
it("does not collapse two distinct sub-millisecond instants to the same value", () => {
// AC-60/62 requires strict ordering between these two: both round to the
// same millisecond under Date.parse (123ms), but differ at the
// microsecond digit — a millisecond-truncating parse would wrongly
// report them equal.
const earlier = parseUpdatedAt("2026-08-20T14:30:05.123400000Z");
const later = parseUpdatedAt("2026-08-20T14:30:05.123500000Z");
expect(earlier).not.toBe(later);
expect(earlier! < later!).toBe(true);
});
it("returns null for an unparseable value (AC-67 candidate)", () => {
expect(parseUpdatedAt("not-a-date")).toBeNull();
expect(parseUpdatedAt("")).toBeNull();
expect(parseUpdatedAt(null)).toBeNull();
expect(parseUpdatedAt(undefined)).toBeNull();
});
});
describe("seedInitialCanonical (AC-59, AC-72)", () => {
it("records the first candidate unconditionally, even if its updated_at is unparseable", () => {
seedInitialCanonical(TASK, "title", "Hello", "garbage");
expect(getCanonicalValue(TASK, "title")).toBe("Hello");
});
it("a same-session revisit re-seed overwrites a stale canonical with a newer value (AC-59, AC-64)", () => {
// A revisit's fresh initial-load seed must be able to supersede a
// canonical value recorded by an earlier visit in the same SPA session —
// otherwise a later failed commit's restore (AC-9/AC-64, which reads the
// canonical value live at restore time) could resurrect a value the
// server has already moved past instead of the value most recently
// loaded from it.
seedInitialCanonical(TASK, "title", "Hello", T10);
seedInitialCanonical(TASK, "title", "Newer on revisit", T11);
expect(getCanonicalValue(TASK, "title")).toBe("Newer on revisit");
});
it("a revisit re-seed does not resurrect a stale value once a newer one is canonical", () => {
// The revisit seed goes through the same AC-60/62 ordering as any other
// candidate — it is not a blind overwrite, just no longer a no-op.
seedInitialCanonical(TASK, "title", "Hello", T11);
seedInitialCanonical(TASK, "title", "Stale revisit", T10);
expect(getCanonicalValue(TASK, "title")).toBe("Hello");
});
it("normalises an absent description to the empty string (AC-71)", () => {
seedInitialCanonical(TASK, "description", undefined as unknown as string, T10);
expect(getCanonicalValue(TASK, "description")).toBe("");
});
it("records the first candidate unconditionally even when its sequence predates the field's last write (AC-72 overrides AC-52)", () => {
// AC-72: until a field has a recorded canonical value, the seed rule
// exempts the next candidate from every other rule, including the AC-52
// staleness check — that check compares against a write, but there is
// nothing yet to be stale relative to. This reproduces the initial page
// load's seed call landing after a title commit was already issued
// (e.g. the authoritative GET resolves late, or is retried, while the
// page already rendered the task interactively from a store cache).
const staleSeq = nextTaskSequence(TASK);
const writeSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", writeSeq);
const accepted = recordRefetchCandidate(TASK, "title", "first ever value", T10, staleSeq);
expect(accepted).toBe(true);
expect(getCanonicalValue(TASK, "title")).toBe("first ever value");
});
});
describe("recordRefetchCandidate ordering (AC-60, AC-62, AC-63, AC-73)", () => {
it("discards a strictly older candidate (AC-60)", () => {
seedInitialCanonical(TASK, "title", "v1", T10);
const accepted = recordRefetchCandidate(
TASK,
"title",
"older",
"2026-08-20T09:00:00Z",
nextTaskSequence(TASK),
);
expect(accepted).toBe(false);
expect(getCanonicalValue(TASK, "title")).toBe("v1");
});
it("accepts a strictly newer candidate", () => {
seedInitialCanonical(TASK, "title", "v1", T10);
const accepted = recordRefetchCandidate(TASK, "title", "newer", T11, nextTaskSequence(TASK));
expect(accepted).toBe(true);
expect(getCanonicalValue(TASK, "title")).toBe("newer");
});
it("breaks an equal-instant tie by request-issue sequence, higher sequence wins (AC-63)", () => {
seedInitialCanonical(TASK, "title", "v1", T10);
const loserSeq = nextTaskSequence(TASK);
const winnerSeq = nextTaskSequence(TASK);
// Winner recorded first, loser arrives after but carries a lower sequence.
expect(recordRefetchCandidate(TASK, "title", "winner", T10, winnerSeq)).toBe(true);
expect(getCanonicalValue(TASK, "title")).toBe("winner");
expect(recordRefetchCandidate(TASK, "title", "loser", T10, loserSeq)).toBe(false);
expect(getCanonicalValue(TASK, "title")).toBe("winner");
});
it("discards a candidate whose updated_at cannot be parsed (AC-67)", () => {
seedInitialCanonical(TASK, "title", "v1", T10);
const accepted = recordRefetchCandidate(
TASK,
"title",
"bad",
"garbage",
nextTaskSequence(TASK),
);
expect(accepted).toBe(false);
expect(getCanonicalValue(TASK, "title")).toBe("v1");
});
it("an unparseable seed loses to any later candidate that does parse (AC-73)", () => {
seedInitialCanonical(TASK, "title", "seed", "garbage");
const accepted = recordRefetchCandidate(
TASK,
"title",
"supersedes",
T10,
nextTaskSequence(TASK),
);
expect(accepted).toBe(true);
expect(getCanonicalValue(TASK, "title")).toBe("supersedes");
});
it("discards a stale SQLite-layout refetch that lands within the same millisecond as an already-recorded RFC3339Nano write (AC-60/62/66)", () => {
// Both instants round to the same millisecond, so a millisecond-precision
// comparison would wrongly treat them as an equal-instant tie (or worse,
// accept the stale one outright) instead of correctly ordering them.
seedInitialCanonical(TASK, "title", "seed", T10);
const writeSeq = nextTaskSequence(TASK);
expect(
recordWriteSuccess(TASK, "title", "from write", "2026-08-20T14:30:05.123500000Z", writeSeq),
).toBe(true);
endWrite(TASK, "title", writeSeq);
const refetchSeq = nextTaskSequence(TASK);
const accepted = recordRefetchCandidate(
TASK,
"title",
"from stale refetch",
"2026-08-20 14:30:05.123400000+00:00",
refetchSeq,
);
expect(accepted).toBe(false);
expect(getCanonicalValue(TASK, "title")).toBe("from write");
});
it("accepts an SQLite-layout refetch that truly postdates an RFC3339Nano write by sub-millisecond microseconds (AC-60/62/66)", () => {
seedInitialCanonical(TASK, "title", "seed", T10);
const writeSeq = nextTaskSequence(TASK);
expect(
recordWriteSuccess(TASK, "title", "from write", "2026-08-20T14:30:05.123400000Z", writeSeq),
).toBe(true);
endWrite(TASK, "title", writeSeq);
const refetchSeq = nextTaskSequence(TASK);
const accepted = recordRefetchCandidate(
TASK,
"title",
"from later refetch",
"2026-08-20 14:30:05.123500000+00:00",
refetchSeq,
);
expect(accepted).toBe(true);
expect(getCanonicalValue(TASK, "title")).toBe("from later refetch");
});
});
describe("recordRefetchCandidate staleness vs a committed write (AC-52)", () => {
it("discards a refetch whose request predates this client's most recent write to the field", () => {
seedInitialCanonical(TASK, "title", "v1", T10);
const refetchSeq = nextTaskSequence(TASK);
const writeSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", writeSeq);
const accepted = recordRefetchCandidate(TASK, "title", "stale", T12, refetchSeq);
expect(accepted).toBe(false);
expect(getCanonicalValue(TASK, "title")).toBe("v1");
});
it("does not apply write-staleness to a write's own response (AC-52 scoped to refetch only)", () => {
seedInitialCanonical(TASK, "title", "v1", T10);
const writeSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", writeSeq);
// A later write is also issued for the same field, making this write's
// sequence no longer "most recent" — its own response must still record.
const laterWriteSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", laterWriteSeq);
const accepted = recordWriteSuccess(TASK, "title", "first-write-result", T11, writeSeq);
expect(accepted).toBe(true);
});
it("still records a non-stale refetch while a write to that field is in flight (AC-49)", () => {
seedInitialCanonical(TASK, "title", "v1", T10);
const writeSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", writeSeq);
const refetchSeq = nextTaskSequence(TASK);
const accepted = recordRefetchCandidate(TASK, "title", "v2-from-refetch", T11, refetchSeq);
expect(accepted).toBe(true);
expect(getCanonicalValue(TASK, "title")).toBe("v2-from-refetch");
// The write is still in flight, so the field remains guarded: recording
// succeeded, but nothing was applied to the displayed value yet (AC-49
// scoped to recording; AC-50 governs the deferred apply on release).
expect(isFieldGuarded(TASK, "title")).toBe(true);
});
});
describe("guard derivation (AC-28, AC-38, AC-68, AC-69, AC-70)", () => {
it("is unguarded by default", () => {
expect(isFieldGuarded(TASK, "title")).toBe(false);
});
it("is guarded while an editor is open", () => {
openFieldEditor(TASK, "title");
expect(isFieldGuarded(TASK, "title")).toBe(true);
closeFieldEditor(TASK, "title");
expect(isFieldGuarded(TASK, "title")).toBe(false);
});
it("is guarded while a write is in flight, independent of editor state", () => {
const seq = nextTaskSequence(TASK);
beginWrite(TASK, "title", seq);
expect(isFieldGuarded(TASK, "title")).toBe(true);
endWrite(TASK, "title", seq);
expect(isFieldGuarded(TASK, "title")).toBe(false);
});
it("stays guarded until both the editor closes and the write resolves", () => {
openFieldEditor(TASK, "title");
const seq = nextTaskSequence(TASK);
beginWrite(TASK, "title", seq);
closeFieldEditor(TASK, "title");
expect(isFieldGuarded(TASK, "title")).toBe(true);
endWrite(TASK, "title", seq);
expect(isFieldGuarded(TASK, "title")).toBe(false);
});
it("endAllEditorsForTask ends only the open-editor contribution, not pending writes (AC-68)", () => {
openFieldEditor(TASK, "title");
const seq = nextTaskSequence(TASK);
beginWrite(TASK, "description", seq);
openFieldEditor(TASK, "description");
endAllEditorsForTask(TASK);
expect(isFieldGuarded(TASK, "title")).toBe(false);
expect(isFieldGuarded(TASK, "description")).toBe(true); // write still pending
endWrite(TASK, "description", seq);
expect(isFieldGuarded(TASK, "description")).toBe(false);
});
it("a write resolving after unmount still records canonical and clears its guard (AC-69)", () => {
const seq = nextTaskSequence(TASK);
beginWrite(TASK, "title", seq);
endAllEditorsForTask(TASK); // simulate unmount; write is still in flight
const accepted = recordWriteSuccess(TASK, "title", "post-unmount", T12, seq);
expect(accepted).toBe(true);
endWrite(TASK, "title", seq);
expect(isFieldGuarded(TASK, "title")).toBe(false);
expect(getCanonicalValue(TASK, "title")).toBe("post-unmount");
});
it("ends a write's guard contribution the same way on failure as on success (AC-70)", () => {
const seq = nextTaskSequence(TASK);
beginWrite(TASK, "title", seq);
// Failure path: caller does NOT call recordWriteSuccess, only endWrite.
endWrite(TASK, "title", seq);
expect(isFieldGuarded(TASK, "title")).toBe(false);
});
});
describe("shouldRestoreAfterFailedWrite (AC-9, AC-44, AC-53, R5-F6)", () => {
it("restores when no other commit to the field is pending or already succeeded", () => {
const seq = nextTaskSequence(TASK);
beginWrite(TASK, "title", seq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", seq)).toBe(true);
});
it("suppresses restore when a later-sequenced commit already succeeded (AC-44)", () => {
const failingSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", failingSeq);
const laterSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", laterSeq);
recordWriteSuccess(TASK, "title", "later-wins", T11, laterSeq);
endWrite(TASK, "title", laterSeq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(false);
});
it("suppresses restore when a later-sequenced commit is still unresolved (AC-53)", () => {
const failingSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", failingSeq);
const laterSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", laterSeq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(false);
});
it("does not suppress restore because of an earlier-sequenced commit, resolved or not (R5-F6)", () => {
const earlierSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", earlierSeq); // still unresolved
const failingSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", failingSeq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(true);
});
it("does not suppress restore when a later-sequenced commit also resolves with failure (AC-65)", () => {
const failingSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", failingSeq);
const laterSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", laterSeq);
// The later commit also fails: caller does not call recordWriteSuccess, only endWrite.
endWrite(TASK, "title", laterSeq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(true);
});
it("chained double failure: earlier commit resolves first, restore is suppressed then completed by the later failure (AC-65)", () => {
// This module never mutates canonical on failure — recordWriteSuccess is
// the only writer, and it is deliberately never called here (both
// commits fail). The two shouldRestoreAfterFailedWrite return values
// below are the actual assertions; the real "does the displayed title
// get restored to canonical" behavior is exercised end-to-end (via
// useCommitTaskTitle's actual restore action) in
// use-optimistic-task-mutation.test.tsx's "chained double failure" tests.
seedInitialCanonical(TASK, "title", "original", T10);
const earlySeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", earlySeq);
const laterSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", laterSeq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", earlySeq)).toBe(false); // AC-53: later still pending
endWrite(TASK, "title", earlySeq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", laterSeq)).toBe(true); // nothing left pending
endWrite(TASK, "title", laterSeq);
});
it("chained double failure: later commit resolves first, both restores fire, canonical unaffected (AC-65)", () => {
// See the comment on the previous test: this only exercises the boolean
// query, not an actual restore. The end-to-end restore-to-canonical
// behavior lives in use-optimistic-task-mutation.test.tsx.
seedInitialCanonical(TASK, "title", "original", T10);
const earlySeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", earlySeq);
const laterSeq = nextTaskSequence(TASK);
beginWrite(TASK, "title", laterSeq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", laterSeq)).toBe(true); // nothing later pending
endWrite(TASK, "title", laterSeq);
expect(shouldRestoreAfterFailedWrite(TASK, "title", earlySeq)).toBe(true); // the later write already resolved
endWrite(TASK, "title", earlySeq);
});
});
describe("recordWriteSuccess ordering relative to guard release (AC-58)", () => {
it("records the canonical value while the write's guard contribution is still held, before the caller releases it", () => {
seedInitialCanonical(TASK, "title", "v1", T10);
const seq = nextTaskSequence(TASK);
beginWrite(TASK, "title", seq);
recordWriteSuccess(TASK, "title", "v2", T11, seq);
// Canonical must already reflect the persisted value even though the
// write's guard contribution has not been released yet (endWrite has not
// run) — AC-58 requires recording strictly before the guard releases,
// not merely "eventually" once it does.
expect(getCanonicalValue(TASK, "title")).toBe("v2");
expect(isFieldGuarded(TASK, "title")).toBe(true);
endWrite(TASK, "title", seq);
expect(isFieldGuarded(TASK, "title")).toBe(false);
});
});
describe("convergence after overlapping successful title commits (AC-54)", () => {
it("converges on the later-persisted title when the earlier commit's response is recorded second", () => {
const seen: string[] = [];
seedInitialCanonical(TASK, "title", "original", T10);
subscribeField(TASK, "title", (value) => seen.push(value));
const seqA = nextTaskSequence(TASK);
beginWrite(TASK, "title", seqA);
const seqB = nextTaskSequence(TASK);
beginWrite(TASK, "title", seqB);
// B was issued after A, but its server response is recorded first while
// A is still in flight, so the field stays guarded and nothing notifies yet.
recordWriteSuccess(TASK, "title", "B-title", T11, seqB);
endWrite(TASK, "title", seqB);
expect(getCanonicalValue(TASK, "title")).toBe("B-title");
expect(seen).toEqual([]);
// A's response arrives second, carrying a later updated_at (it was
// persisted after B on the server) and releases the guard.
recordWriteSuccess(TASK, "title", "A-title", T12, seqA);
endWrite(TASK, "title", seqA);
expect(getCanonicalValue(TASK, "title")).toBe("A-title");
expect(seen).toEqual(["A-title"]);
});
it("converges on the same later-persisted title regardless of which response is recorded first", () => {
seedInitialCanonical(TASK, "title", "original", T10);
const seqA = nextTaskSequence(TASK);
beginWrite(TASK, "title", seqA);
const seqB = nextTaskSequence(TASK);
beginWrite(TASK, "title", seqB);
// This time the earlier-issued commit's response, carrying the earlier
// updated_at, is recorded first.
recordWriteSuccess(TASK, "title", "A-title", T11, seqA);
endWrite(TASK, "title", seqA);
recordWriteSuccess(TASK, "title", "B-title", T12, seqB);
endWrite(TASK, "title", seqB);
expect(getCanonicalValue(TASK, "title")).toBe("B-title");
});
});
describe("deferred-apply notification (AC-27, AC-50)", () => {
it("notifies immediately when a candidate is accepted while unguarded", () => {
const seen: string[] = [];
subscribeField(TASK, "title", (value) => seen.push(value));
seedInitialCanonical(TASK, "title", "v1", T10);
recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
expect(seen).toEqual(["v1", "v2"]);
});
it("defers notification while guarded, then fires once the guard releases", () => {
const seen: string[] = [];
seedInitialCanonical(TASK, "title", "v1", T10);
subscribeField(TASK, "title", (value) => seen.push(value));
openFieldEditor(TASK, "title");
recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
expect(seen).toEqual([]); // guarded: no notification yet
closeFieldEditor(TASK, "title");
expect(seen).toEqual(["v2"]);
});
it("unsubscribe stops further notifications", () => {
const seen: string[] = [];
const unsubscribe = subscribeField(TASK, "title", (value) => seen.push(value));
seedInitialCanonical(TASK, "title", "v1", T10);
unsubscribe();
recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
expect(seen).toEqual(["v1"]);
});
});
describe("multi-task isolation", () => {
it("does not leak canonical state between tasks", () => {
seedInitialCanonical("task-a", "title", "A", T10);
seedInitialCanonical("task-b", "title", "B", T10);
expect(getCanonicalValue("task-a", "title")).toBe("A");
expect(getCanonicalValue("task-b", "title")).toBe("B");
});
});