mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
b62eb83ad4
#493 was not the last instance of the kv-Date hazard — it was the most reachable. Audit of the rest of the learning pipeline found 11 more unprotected kv-read sites where TypeScript declares `Date` but JSON round-trip through `memory.get` yields an ISO string at runtime. Two failure modes, both already documented at learning-coordinator.ts:898-904 (#491 Bug 3): 1. THROW — `record.someDate.getTime()` raises TypeError. Surfaces immediately on the first invocation once any record exists. 2. SILENT — `timeRange.contains(record.someDate)` or `new Date() > record.expiresAt` coerces string vs Date to NaN; all comparisons return false; records are silently filtered out or expiry is never recognised. No error, just empty/stale analytics. The silent variant is the worse one — #491 Bug 3 was the same class and shipped for a release cycle undetected. Strategic fix: a single `rehydrateDates<T>(record, dateFields[])` helper at `src/shared/utils/kv-date-rehydrate.ts` applied at every kv-read seam that touches Date-bearing types. Shallow-copies the record and coerces named string fields back to Date; pass-through for already-Date, null, undefined. Sites patched (11 total): src/domains/learning-optimization/services/ production-intel.ts — 4 sites (HIGH #2-#5 throw, MEDIUM #8 silent) getMetricsHistory, getRecentIncidents (×2), resolveIncident, getRecentMilestones metrics-optimizer.ts — 1 site (HIGH #1 throw) getMetricsHistory.sort learning-coordinator.ts — 1 site (HIGH #6 throw) getReplayBuffer.sort transfer-specialist.ts — 4 sites (HIGH #7 throw, MEDIUM #9 silent) queryKnowledge (×2), getKnowledgeById, bulkTransfer src/domains/learning-optimization/ coordinator-helpers.ts — 1 site (SILENT) getExperiencesForDomain coordinator.ts — 1 site (SILENT, duplicate of above) getExperiencesForDomain (private) Sites verified SAFE (kv-read but only access non-Date fields): - coordinator.ts:1045/1134/1175 — uses .domain/.confidence/.version - metrics-optimizer.ts:676 — uses .metrics map - learning-coordinator.ts:379/438/483/547/830/842 — uses .confidence/.successRate/.domain/.usageCount - learning-coordinator.ts:901 — already has #491 Bug 3 inline rehydration with instanceof guard - experience-capture.ts:520/563 — TaskExperience uses number timestamps, not Date Sites NOT patched (deferred per scope): - coordination/protocols/learning-consolidation.ts — defined but never imported anywhere. Dead code; would resurrect the bug if reactivated. Documented in the audit but not fixed. Regression tests (29 new, 6 files): tests/unit/shared/utils/kv-date-rehydrate.test.ts (10) tests/unit/domains/learning-optimization/ production-intel-date-coercion.test.ts (5) metrics-optimizer-date-coercion.test.ts (2) learning-coordinator-date-coercion.test.ts (2) transfer-specialist-date-coercion.test.ts (5) coordinator-helpers-date-coercion.test.ts (2) Each service test seeds its mock memory backend with kv-shape records (ISO strings instead of Date instances — matching the post-JSON.parse shape that better-sqlite3-backed memory yields in production) and exercises the kv-read → consumer path that would fail pre-fix. Both throw and silent failure modes are pinned: throws are asserted not to fire, silent failures are caught by behavioural assertions (e.g. the expired-knowledge test asserts `expired.score < live.score * 0.5 ±`, which would fail pre-fix because the 0.5 penalty never triggered). Verification: - 29/29 new regression tests green - 135/135 existing tests for patched services green (production-intel, metrics-optimizer, transfer-specialist, learning-coordinator, learning-consolidation, learning-consolidation-mining, learning-consolidation-liveness) - `tsc --noEmit` clean - `npm run build` produces dist with rehydrateDates at all 17 expected call sites across the 6 patched files Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
5.6 KiB
TypeScript
167 lines
5.6 KiB
TypeScript
/**
|
|
* Unit tests for the kv-Date rehydration helper.
|
|
*
|
|
* Pins the contract that the cross-service kv-Date fixes depend on. If this
|
|
* regresses, every consumer (production-intel, metrics-optimizer,
|
|
* transfer-specialist, learning-coordinator.getReplayBuffer) loses its
|
|
* protection against the #493 / #491-Bug-3 bug class.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { rehydrateDates, rehydrateDatesAll } from '../../../../src/shared/utils/kv-date-rehydrate';
|
|
|
|
interface SampleRecord {
|
|
id: string;
|
|
createdAt: Date;
|
|
expiresAt?: Date;
|
|
payload: { tag: string };
|
|
}
|
|
|
|
describe('rehydrateDates', () => {
|
|
it('coerces an ISO-string field to a real Date', () => {
|
|
const iso = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
const kvRecord = {
|
|
id: 'r1',
|
|
createdAt: iso as unknown as Date,
|
|
payload: { tag: 'a' },
|
|
} as SampleRecord;
|
|
|
|
const result = rehydrateDates(kvRecord, ['createdAt'])!;
|
|
|
|
expect(result.createdAt).toBeInstanceOf(Date);
|
|
expect(result.createdAt.toISOString()).toBe(iso);
|
|
// Non-date fields untouched.
|
|
expect(result.id).toBe('r1');
|
|
expect(result.payload).toEqual({ tag: 'a' });
|
|
});
|
|
|
|
it('returns a shallow COPY — does not mutate the input', () => {
|
|
const iso = new Date().toISOString();
|
|
const input = {
|
|
id: 'r1',
|
|
createdAt: iso as unknown as Date,
|
|
payload: { tag: 'a' },
|
|
} as SampleRecord;
|
|
|
|
const result = rehydrateDates(input, ['createdAt'])!;
|
|
|
|
expect(result).not.toBe(input);
|
|
// Input's runtime shape is preserved — important when the helper is
|
|
// called from a service that hands the same record to multiple readers.
|
|
expect(typeof (input as unknown as { createdAt: unknown }).createdAt).toBe('string');
|
|
});
|
|
|
|
it('leaves an already-Date field untouched', () => {
|
|
const realDate = new Date();
|
|
const input = {
|
|
id: 'r1',
|
|
createdAt: realDate,
|
|
payload: { tag: 'a' },
|
|
} as SampleRecord;
|
|
|
|
const result = rehydrateDates(input, ['createdAt'])!;
|
|
|
|
expect(result.createdAt).toBe(realDate); // Same instance — no needless allocation.
|
|
});
|
|
|
|
it('handles optional Date fields cleanly', () => {
|
|
const iso = new Date().toISOString();
|
|
const onlyCreated = {
|
|
id: 'r1',
|
|
createdAt: iso as unknown as Date,
|
|
payload: { tag: 'a' },
|
|
} as SampleRecord;
|
|
|
|
const withExpiry = {
|
|
id: 'r2',
|
|
createdAt: iso as unknown as Date,
|
|
expiresAt: iso as unknown as Date,
|
|
payload: { tag: 'b' },
|
|
} as SampleRecord;
|
|
|
|
const r1 = rehydrateDates(onlyCreated, ['createdAt', 'expiresAt'])!;
|
|
const r2 = rehydrateDates(withExpiry, ['createdAt', 'expiresAt'])!;
|
|
|
|
expect(r1.createdAt).toBeInstanceOf(Date);
|
|
expect(r1.expiresAt).toBeUndefined();
|
|
expect(r2.createdAt).toBeInstanceOf(Date);
|
|
expect(r2.expiresAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('returns null/undefined inputs as-is', () => {
|
|
expect(rehydrateDates(null, ['createdAt'])).toBeNull();
|
|
expect(rehydrateDates(undefined, ['createdAt'])).toBeUndefined();
|
|
});
|
|
|
|
it('coerces multiple fields in a single call', () => {
|
|
const isoCreated = '2026-05-01T00:00:00.000Z';
|
|
const isoExpires = '2026-06-01T00:00:00.000Z';
|
|
const input = {
|
|
id: 'r1',
|
|
createdAt: isoCreated as unknown as Date,
|
|
expiresAt: isoExpires as unknown as Date,
|
|
payload: { tag: 'a' },
|
|
} as SampleRecord;
|
|
|
|
const result = rehydrateDates(input, ['createdAt', 'expiresAt'])!;
|
|
|
|
expect(result.createdAt.toISOString()).toBe(isoCreated);
|
|
expect(result.expiresAt!.toISOString()).toBe(isoExpires);
|
|
});
|
|
|
|
it('round-trip safety: rehydrated Dates round-trip cleanly through .getTime()', () => {
|
|
// This is THE assertion that pins the #493 fix: post-rehydrate, the
|
|
// downstream consumer's `.getTime()` returns a sensible number, not a
|
|
// throw. Mirrors the exact failure mode from the issue.
|
|
const iso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
const kvRecord = {
|
|
id: 'r1',
|
|
createdAt: iso as unknown as Date,
|
|
payload: { tag: 'a' },
|
|
} as SampleRecord;
|
|
|
|
const result = rehydrateDates(kvRecord, ['createdAt'])!;
|
|
|
|
// The original kv shape would throw here.
|
|
expect(() => result.createdAt.getTime()).not.toThrow();
|
|
expect(result.createdAt.getTime()).toBe(new Date(iso).getTime());
|
|
});
|
|
});
|
|
|
|
describe('rehydrateDatesAll', () => {
|
|
it('rehydrates every record in an array', () => {
|
|
const iso1 = '2026-05-01T00:00:00.000Z';
|
|
const iso2 = '2026-05-02T00:00:00.000Z';
|
|
const records: SampleRecord[] = [
|
|
{ id: 'a', createdAt: iso1 as unknown as Date, payload: { tag: 'x' } },
|
|
{ id: 'b', createdAt: iso2 as unknown as Date, payload: { tag: 'y' } },
|
|
];
|
|
|
|
const result = rehydrateDatesAll(records, ['createdAt']);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]!.createdAt).toBeInstanceOf(Date);
|
|
expect(result[1]!.createdAt).toBeInstanceOf(Date);
|
|
expect(result[0]!.createdAt.toISOString()).toBe(iso1);
|
|
});
|
|
|
|
it('filters out null/undefined entries (common when memory.get returns nothing)', () => {
|
|
const iso = new Date().toISOString();
|
|
const records = [
|
|
{ id: 'a', createdAt: iso as unknown as Date, payload: { tag: 'x' } },
|
|
null,
|
|
undefined,
|
|
{ id: 'b', createdAt: iso as unknown as Date, payload: { tag: 'y' } },
|
|
] as Array<SampleRecord | null | undefined>;
|
|
|
|
const result = rehydrateDatesAll(records, ['createdAt']);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('returns an empty array for an empty input', () => {
|
|
expect(rehydrateDatesAll([], ['createdAt'])).toEqual([]);
|
|
});
|
|
});
|