test: add frontend unit suite + fix mixed local/UTC timestamps
Two CODE_AUDIT items in one session. §3.2 — Frontend regression net (js/tests/, 35 tests, node:test, zero deps): - harness.js loads app.js (monofile, no exports) into a node:vm with browser globals stubbed, surfacing internals via an export epilogue. - crypto: deriveKeyAndVerifier (AES key == raw PBKDF2, cross-checked vs Node pbkdf2Sync), legacy-vs-v2 verifier decoupling, encrypt/decrypt round-trip, IV uniqueness, AEAD tamper/wrong-key. - csv: parseCSV tokenizer, findColumn heuristics, Bitwarden/KeePass mapping. - merge: applyRemoteSnapshot add/update/skip (LWW), tombstone delete, resurrection arbitration (both NaN branches), local-tombstone veto, additive folder merge. Only api() is stubbed; loadEntries/encryptImportEntry run for real. - Wired as a build gate in BuildAssets.ps1 (after node --check, bypass PM_SKIP_TESTS=1). §2.2 — Unify timestamps on UTC: - Entry created_at/updated_at were written via Delphi FormatDateTime(Now) = LOCAL, while deleted_at/tombstones use SQLite CURRENT_TIMESTAMP = UTC. The tombstone-resurrection arbitration compared the two zones, skewing by the machine's UTC offset even single-device. - Add NowUTC/NowUTCStr to PM.Database, swap in at every entry/attachment write site (Entries create/update/bulk, Attachments POST echo). - No JS change needed: arbitration now compares same-zone values. - Existing rows self-heal on next edit (no destructive migration). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -210,7 +210,9 @@ begin
|
||||
LObj.AddPair('filename', LFilename);
|
||||
LObj.AddPair('mime', LMime);
|
||||
LObj.AddPair('size_bytes', TJSONNumber.Create(LSize));
|
||||
LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', Now));
|
||||
// UTC to match the stored value (schema default CURRENT_TIMESTAMP is UTC)
|
||||
// and the GET-list formatter — the POST response echoed local time before.
|
||||
LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', NowUTC));
|
||||
TJSONHelper.SendJSON(AResponse, LObj);
|
||||
end;
|
||||
|
||||
|
||||
@@ -350,7 +350,7 @@ begin
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now);
|
||||
LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2)
|
||||
|
||||
DB.Lock;
|
||||
try
|
||||
@@ -493,7 +493,7 @@ begin
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now);
|
||||
LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2)
|
||||
DB.Lock;
|
||||
try
|
||||
LQ := TFDQuery.Create(nil);
|
||||
@@ -1138,7 +1138,7 @@ begin
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now);
|
||||
LNow := NowUTCStr; // UTC — matches SQLite CURRENT_TIMESTAMP (see CODE_AUDIT §2.2)
|
||||
LImported := 0;
|
||||
// Track newly-inserted IDs in input order so the client can upload
|
||||
// attachments to the right entry afterwards. Skipped rows emit -1
|
||||
|
||||
@@ -48,8 +48,29 @@ var
|
||||
procedure InitDatabase(const ADBPath: string);
|
||||
procedure DoneDatabase;
|
||||
|
||||
// Current time as UTC. SQLite's CURRENT_TIMESTAMP / datetime('now') already
|
||||
// emit UTC, so every Delphi-written timestamp (created_at, updated_at,
|
||||
// deleted_at, …) MUST use these — mixing Delphi's local-time Now with the
|
||||
// SQLite-UTC values silently skews the sync last-write-wins / tombstone
|
||||
// resurrection arbitration by the machine's UTC offset (see CODE_AUDIT §2.2).
|
||||
function NowUTC: TDateTime;
|
||||
function NowUTCStr: string; // 'yyyy-mm-dd hh:nn:ss' in UTC
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.DateUtils; // TTimeZone for local→UTC conversion
|
||||
|
||||
function NowUTC: TDateTime;
|
||||
begin
|
||||
Result := TTimeZone.Local.ToUniversalTime(Now);
|
||||
end;
|
||||
|
||||
function NowUTCStr: string;
|
||||
begin
|
||||
Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', NowUTC);
|
||||
end;
|
||||
|
||||
constructor TPMDatabase.Create(const ADBPath: string);
|
||||
begin
|
||||
inherited Create;
|
||||
|
||||
@@ -102,8 +102,34 @@ if ($jsFiles) {
|
||||
}
|
||||
}
|
||||
Log "JS syntax OK."
|
||||
|
||||
# --- Unit test gate ---------------------------------------------------
|
||||
# Run the frontend regression suite (crypto round-trip, CSV import,
|
||||
# sync-merge arbitration) before embedding. A broken crypto/merge
|
||||
# invariant now blocks the build the same way a syntax error does,
|
||||
# instead of surfacing only after a full Delphi rebuild + manual test.
|
||||
# ~1.7 s, zero deps (node:test). Skipped automatically if the suite
|
||||
# isn't present. Set PM_SKIP_TESTS=1 to bypass during rapid iteration.
|
||||
if ($env:PM_SKIP_TESTS -eq '1') {
|
||||
Log "PM_SKIP_TESTS=1 - skipping unit tests."
|
||||
} elseif (Test-Path (Join-Path $WebRoot 'js\tests')) {
|
||||
Log "Running frontend unit tests..."
|
||||
Push-Location $WebRoot
|
||||
try {
|
||||
$testOut = $null | & $node.Source --test 'js/tests/**/*.test.js' 2>&1
|
||||
$testExit = $LASTEXITCODE
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
if ($testExit -ne 0) {
|
||||
Log "UNIT TESTS FAILED:"
|
||||
Log ($testOut | Out-String)
|
||||
throw "Frontend unit tests failed - aborting asset build. (Set PM_SKIP_TESTS=1 to bypass.)"
|
||||
}
|
||||
Log "Unit tests OK."
|
||||
}
|
||||
} else {
|
||||
Log "WARNING: node not found - skipping JS syntax check. Install Node to enable it."
|
||||
Log "WARNING: node not found - skipping JS syntax check + unit tests. Install Node to enable them."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user