Files
msd-core/tests/research-store.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

640 lines
25 KiB
JavaScript

'use strict';
/**
* Behavioral tests for research-store.cjs
*
* No source-grep. All tests call exported functions and assert on returned objects.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const {
researchKey,
ttlForSource,
resolveStorePath,
putResearch,
getResearch,
} = require('../msd-core/bin/lib/research-store.cjs');
// ---------------------------------------------------------------------------
// Cycle 2: researchKey deterministic + sensitive
// ---------------------------------------------------------------------------
describe('research-store: researchKey deterministic + sensitive', () => {
const base = { ecosystem: 'npm', library: 'lodash', version: '4.17.21', query: 'chunk', kind: 'docs' };
test('same inputs produce the same key', () => {
const k1 = researchKey({ ...base });
const k2 = researchKey({ ...base });
assert.equal(k1, k2);
});
test('key is a 64-char hex sha256', () => {
const k = researchKey(base);
assert.match(k, /^[0-9a-f]{64}$/);
});
test('changing ecosystem changes the key', () => {
assert.notEqual(researchKey({ ...base, ecosystem: 'pypi' }), researchKey(base));
});
test('changing library changes the key', () => {
assert.notEqual(researchKey({ ...base, library: 'underscore' }), researchKey(base));
});
test('changing version changes the key', () => {
assert.notEqual(researchKey({ ...base, version: '3.0.0' }), researchKey(base));
});
test('changing query changes the key', () => {
assert.notEqual(researchKey({ ...base, query: 'merge' }), researchKey(base));
});
test('changing kind changes the key', () => {
assert.notEqual(researchKey({ ...base, kind: 'web' }), researchKey(base));
});
test('never throws on arbitrary/missing inputs', () => {
assert.doesNotThrow(() => researchKey({}));
assert.doesNotThrow(() => researchKey({ ecosystem: null, library: undefined, version: 42, query: '', kind: false }));
});
});
// ---------------------------------------------------------------------------
// Cycle 4: getResearch on missing key → {hit:false, stale:false, entry:null}
// ---------------------------------------------------------------------------
describe('research-store: getResearch missing key', () => {
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
test('returns {hit:false, stale:false, entry:null} for missing key, does not throw', () => {
assert.doesNotThrow(() => {
const result = getResearch(tmpCwd, 'nonexistentkey', { homeDir: tmpHome });
assert.equal(result.hit, false);
assert.equal(result.stale, false);
assert.equal(result.entry, null);
});
});
test('returns {hit:false} when kind omitted and key absent in both tiers', () => {
const result = getResearch(tmpCwd, 'nonexistentkey2', { homeDir: tmpHome });
assert.equal(result.hit, false);
assert.equal(result.stale, false);
assert.equal(result.entry, null);
});
});
// ---------------------------------------------------------------------------
// Cycle 5: getResearch on corrupt entry file → {hit:false, stale:false, entry:null}
// ---------------------------------------------------------------------------
describe('research-store: getResearch corrupt file', () => {
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
test('returns {hit:false, stale:false, entry:null} on corrupt JSON, does not throw', () => {
// Write garbage JSON to the expected path for a 'web' source (project tier)
const dir = resolveStorePath(tmpCwd, 'web', { homeDir: tmpHome });
fs.mkdirSync(dir, { recursive: true });
const corruptKey = 'corruptkey123';
fs.writeFileSync(path.join(dir, `${corruptKey}.json`), '{');
assert.doesNotThrow(() => {
const result = getResearch(tmpCwd, corruptKey, { homeDir: tmpHome });
assert.equal(result.hit, false);
assert.equal(result.stale, false);
assert.equal(result.entry, null);
});
});
});
// ---------------------------------------------------------------------------
// Cycle 6: ttlForSource policy — 30d / 7d / 1d
// ---------------------------------------------------------------------------
describe('research-store: ttlForSource policy', () => {
const DAY_MS = 86_400_000;
test('curated + HIGH → 30 days', () => {
assert.equal(ttlForSource('curated', 'HIGH'), 30 * DAY_MS);
});
test('curated + MEDIUM → 7 days', () => {
assert.equal(ttlForSource('curated', 'MEDIUM'), 7 * DAY_MS);
});
test('web source → 1 day (regardless of confidence)', () => {
assert.equal(ttlForSource('web', 'HIGH'), DAY_MS);
});
test('confidence LOW → 1 day (regardless of source)', () => {
assert.equal(ttlForSource('curated', 'LOW'), DAY_MS);
});
test('default (unknown source + confidence) → 1 day', () => {
assert.equal(ttlForSource('unknown', 'UNKNOWN'), DAY_MS);
});
});
// ---------------------------------------------------------------------------
// Cycle 7: STALENESS BOUNDARY (clock seam)
// Put at clock now=0; ttl = 30d (curated/HIGH = 2592000000ms)
// now = ttl-1 → stale:false
// now = ttl → stale:false (strict >; equal is NOT stale)
// now = ttl+1 → stale:true
// ---------------------------------------------------------------------------
describe('research-store: staleness boundary (clock seam)', () => {
const DAY_MS = 86_400_000;
const TTL_30D = 30 * DAY_MS;
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
function putAtZero(cwd, home, key) {
const clockZero = { now: () => 0 };
// version:'4.17.21' prevents the blank-version TTL cap so TTL stays at 30d
putResearch(
cwd,
key,
{ content: 'data', source: 'curated', provider: 'p', confidence: 'HIGH', kind: 'docs', version: '4.17.21' },
{ clock: clockZero, homeDir: home }
);
}
test('now = ttl-1 → stale:false', () => {
const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-minus-1' });
putAtZero(tmpCwd, tmpHome, key);
const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D - 1 }, homeDir: tmpHome });
assert.equal(result.hit, true);
assert.equal(result.stale, false, 'age = ttl-1 should NOT be stale');
});
test('now = ttl → stale:false (strict > boundary: equal is not stale)', () => {
const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-exact' });
putAtZero(tmpCwd, tmpHome, key);
const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D }, homeDir: tmpHome });
assert.equal(result.hit, true);
assert.equal(result.stale, false, 'age = ttl exactly should NOT be stale (strict >)');
});
test('now = ttl+1 → stale:true', () => {
const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-plus-1' });
putAtZero(tmpCwd, tmpHome, key);
const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D + 1 }, homeDir: tmpHome });
assert.equal(result.hit, true);
assert.equal(result.stale, true, 'age = ttl+1 should be stale');
});
});
// ---------------------------------------------------------------------------
// Cycle 8: resolveStorePath tiers — source-derived (I1)
// ---------------------------------------------------------------------------
describe('research-store: resolveStorePath tiers', () => {
const FAKE_HOME = '/fake/home';
const FAKE_CWD = '/fake/cwd';
test("source 'curated' → under injected homeDir/.msd/research-cache", () => {
const p = resolveStorePath(FAKE_CWD, 'curated', { homeDir: FAKE_HOME });
assert.equal(p, path.join(FAKE_HOME, '.msd', 'research-cache'));
});
test("source 'web' (project) → under cwd/.planning/research/.cache", () => {
const p = resolveStorePath(FAKE_CWD, 'web', { homeDir: FAKE_HOME });
assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache'));
});
test("source 'synthesis' (project) → under cwd/.planning/research/.cache", () => {
const p = resolveStorePath(FAKE_CWD, 'synthesis', { homeDir: FAKE_HOME });
assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache'));
});
test("source 'legitimacy' (project) → under cwd/.planning/research/.cache", () => {
const p = resolveStorePath(FAKE_CWD, 'legitimacy', { homeDir: FAKE_HOME });
assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache'));
});
test('paths are absolute', () => {
const curated = resolveStorePath(FAKE_CWD, 'curated', { homeDir: FAKE_HOME });
const web = resolveStorePath(FAKE_CWD, 'web', { homeDir: FAKE_HOME });
assert.ok(path.isAbsolute(curated));
assert.ok(path.isAbsolute(web));
});
});
// ---------------------------------------------------------------------------
// I1 REGRESSION: putResearch with source:'web', kind:'docs' → project tier
// (currently fails: kind:'docs' forces user tier regardless of source)
// ---------------------------------------------------------------------------
describe('research-store: I1 regression — source is the tier axis', () => {
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-i1-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-i1-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
test('source:web + kind:docs → file in PROJECT dir, NOT user dir', () => {
const key = researchKey({ ecosystem: 'npm', library: 'axios', version: '1.0.0', query: 'get', kind: 'docs' });
putResearch(
tmpCwd,
key,
{ content: 'web data', source: 'web', provider: 'web', confidence: 'HIGH', kind: 'docs' },
{ homeDir: tmpHome }
);
const projectFile = path.join(tmpCwd, '.planning', 'research', '.cache', `${key}.json`);
const userFile = path.join(tmpHome, '.msd', 'research-cache', `${key}.json`);
assert.ok(fs.existsSync(projectFile), 'file should exist in project dir (.planning/research/.cache)');
assert.ok(!fs.existsSync(userFile), 'file should NOT exist in user dir (~/.msd/research-cache)');
});
test('source:curated + kind:web → file in USER dir, NOT project dir', () => {
const key = researchKey({ ecosystem: 'npm', library: 'zod', version: '3.0.0', query: 'parse', kind: 'web' });
putResearch(
tmpCwd,
key,
{ content: 'curated data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'web' },
{ homeDir: tmpHome }
);
const projectFile = path.join(tmpCwd, '.planning', 'research', '.cache', `${key}.json`);
const userFile = path.join(tmpHome, '.msd', 'research-cache', `${key}.json`);
assert.ok(fs.existsSync(userFile), 'file should exist in user dir (~/.msd/research-cache)');
assert.ok(!fs.existsSync(projectFile), 'file should NOT exist in project dir');
});
});
// ---------------------------------------------------------------------------
// W4 REGRESSION: getResearch prefers FRESHEST across both tiers
// (currently returns first-match regardless of staleness)
// ---------------------------------------------------------------------------
describe('research-store: W4a regression — prefer fresh over stale across tiers', () => {
const DAY_MS = 86_400_000;
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-w4-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-w4-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
test('fresh project entry wins over stale curated entry for same key', () => {
// After fix: source derives tier. We use distinct source values so entries land in different tiers.
// We compute the key without kind so both entries share the same key.
const key = researchKey({ ecosystem: 'npm', library: 'react', version: '18.0.0', query: 'hooks' });
// Seed a STALE curated entry directly into user tier directory
const userDir = path.join(tmpHome, '.msd', 'research-cache');
fs.mkdirSync(userDir, { recursive: true });
const staleEntry = {
content: 'stale curated content',
source: 'curated',
provider: 'ctx7',
confidence: 'HIGH',
fetched_at: new Date(0).toISOString(), // epoch → always stale at t=100d
ttl: 30 * DAY_MS,
kind: 'docs',
};
fs.writeFileSync(path.join(userDir, `${key}.json`), JSON.stringify(staleEntry));
// Seed a FRESH web entry directly into project tier directory
const freshClock = { now: () => 100 * DAY_MS }; // well beyond the curated entry's TTL
const projectDir = path.join(tmpCwd, '.planning', 'research', '.cache');
fs.mkdirSync(projectDir, { recursive: true });
const freshEntry = {
content: 'fresh web content',
source: 'web',
provider: 'web',
confidence: 'HIGH',
fetched_at: new Date(100 * DAY_MS).toISOString(), // brand new
ttl: DAY_MS,
kind: 'docs',
};
fs.writeFileSync(path.join(projectDir, `${key}.json`), JSON.stringify(freshEntry));
// Now at freshClock time, curated is stale (age=100d > 30d ttl) but web is fresh (age=0 < 1d ttl)
const result = getResearch(tmpCwd, key, { clock: freshClock, homeDir: tmpHome });
assert.equal(result.hit, true, 'should find a hit');
assert.equal(result.stale, false, 'should return the FRESH entry (stale:false)');
assert.equal(result.entry.content, 'fresh web content', 'should return fresh web content, not stale curated');
assert.equal(result.entry.source, 'web', 'source should be web');
});
});
// ---------------------------------------------------------------------------
// W4b REGRESSION: blank version caps TTL at DAY_MS
// (currently blank version still gets 30d curated TTL)
// ---------------------------------------------------------------------------
describe('research-store: W4b regression — blank version caps TTL', () => {
const DAY_MS = 86_400_000;
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-w4b-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-w4b-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
test('curated + HIGH + version blank → ttl = DAY_MS (capped)', () => {
const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '', query: 'chunk', kind: 'docs' });
const entry = putResearch(
tmpCwd,
key,
{ content: 'data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'docs', version: '' },
{ homeDir: tmpHome }
);
assert.equal(entry.ttl, DAY_MS, 'blank version should cap TTL at DAY_MS, not 30*DAY_MS');
});
test('curated + HIGH + version "1.2.3" → ttl = 30 * DAY_MS (uncapped)', () => {
const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '1.2.3', query: 'chunk', kind: 'docs' });
const entry = putResearch(
tmpCwd,
key,
{ content: 'data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'docs', version: '1.2.3' },
{ homeDir: tmpHome }
);
assert.equal(entry.ttl, 30 * DAY_MS, 'non-blank version should NOT cap TTL');
});
});
// ---------------------------------------------------------------------------
// Cycle 1: TRACER BULLET — round-trip put then get
// ---------------------------------------------------------------------------
describe('research-store: tracer bullet round-trip', () => {
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
test('put then get returns hit:true, stale:false, entry with content preserved', () => {
const fixedClock = { now: () => 0 };
const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '4.17.21', query: 'chunk', kind: 'docs' });
const stored = putResearch(
tmpCwd,
key,
{ content: 'lodash chunk docs', source: 'curated', provider: 'npm', confidence: 'HIGH', kind: 'docs' },
{ clock: fixedClock, homeDir: tmpHome }
);
assert.equal(stored.content, 'lodash chunk docs', 'putResearch returns entry with content');
const result = getResearch(tmpCwd, key, { clock: fixedClock, homeDir: tmpHome });
assert.equal(result.hit, true, 'hit should be true');
assert.equal(result.stale, false, 'stale should be false at time 0');
assert.ok(result.entry !== null, 'entry should not be null');
assert.equal(result.entry.content, 'lodash chunk docs', 'content preserved');
assert.equal(result.entry.source, 'curated');
assert.equal(result.entry.confidence, 'HIGH');
});
});
// ---------------------------------------------------------------------------
// FINDING 1 REGRESSION: key validation / path-traversal prevention
// ---------------------------------------------------------------------------
describe('research-store: isValidResearchKey exported', () => {
const { isValidResearchKey } = require('../msd-core/bin/lib/research-store.cjs');
test('isValidResearchKey is exported', () => {
assert.equal(typeof isValidResearchKey, 'function', 'isValidResearchKey must be exported');
});
test('64-char hex key is valid', () => {
assert.equal(isValidResearchKey('a'.repeat(64)), true);
assert.equal(isValidResearchKey('0123456789abcdef'.repeat(4)), true);
});
test('short key is invalid', () => {
assert.equal(isValidResearchKey('abc'), false);
});
test('traversal key is invalid', () => {
assert.equal(isValidResearchKey('../../../etc/passwd'), false);
});
test('non-hex 64-char key is invalid', () => {
assert.equal(isValidResearchKey('g'.repeat(64)), false);
});
test('non-string is invalid', () => {
assert.equal(isValidResearchKey(null), false);
assert.equal(isValidResearchKey(undefined), false);
assert.equal(isValidResearchKey(123), false);
});
});
describe('research-store: putResearch rejects traversal key', () => {
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-trav-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-trav-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
test('putResearch throws on traversal key and does NOT write any file outside cache dir', () => {
const traversalKey = '../../../' + 'x'.repeat(10);
// Verify no file is created outside
const outsideTarget = path.join(os.tmpdir(), 'x'.repeat(10) + '.json');
// Remove any pre-existing file at traversal target
try { fs.unlinkSync(outsideTarget); } catch { /* ignore */ }
assert.throws(
() => putResearch(tmpCwd, traversalKey, { content: 'evil', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome }),
/invalid research key/i
);
assert.equal(fs.existsSync(outsideTarget), false, 'traversal target must not be created');
});
test('putResearch throws on short/fake key', () => {
assert.throws(
() => putResearch(tmpCwd, 'abc', { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome }),
/invalid research key/i
);
});
test('putResearch succeeds with valid 64-hex key', () => {
const key = researchKey({ ecosystem: 'npm', library: 'react', version: '18.0.0', query: 'hooks', kind: 'docs' });
assert.doesNotThrow(() => {
putResearch(tmpCwd, key, { content: 'ok', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome });
});
});
});
describe('research-store: getResearch rejects traversal key', () => {
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-trav2-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-trav2-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
test('getResearch returns {hit:false} on traversal key and does NOT read outside cache dir', () => {
const traversalKey = '../../../etc/passwd';
const result = getResearch(tmpCwd, traversalKey, { homeDir: tmpHome });
assert.equal(result.hit, false, 'traversal key must return hit:false');
assert.equal(result.stale, false);
assert.equal(result.entry, null);
});
test('getResearch returns {hit:false} on short key', () => {
const result = getResearch(tmpCwd, 'k1', { homeDir: tmpHome });
assert.equal(result.hit, false);
});
});
// ---------------------------------------------------------------------------
// FINDING 3 REGRESSION: malformed cache metadata treated as fresh
// ---------------------------------------------------------------------------
describe('research-store: getResearch rejects malformed cache metadata', () => {
let tmpCwd;
let tmpHome;
beforeEach(() => {
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-malformed-cwd-'));
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-rs-malformed-home-'));
});
afterEach(() => {
cleanup(tmpCwd);
cleanup(tmpHome);
});
function writeEntry(dir, key, entry) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${key}.json`), JSON.stringify(entry));
}
test('missing fetched_at → hit:false (not treated as fresh)', () => {
const key = researchKey({ ecosystem: 'npm', library: 'bad', version: '1.0.0', query: 'q', kind: 'docs' });
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', ttl: 86400000 });
// fetched_at is missing
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
assert.equal(result.hit, false, 'missing fetched_at must return hit:false');
});
test('ttl = "abc" (string) → hit:false', () => {
const key = researchKey({ ecosystem: 'npm', library: 'bad2', version: '1.0.0', query: 'q', kind: 'docs' });
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 'abc' });
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
assert.equal(result.hit, false, 'string ttl must return hit:false');
});
test('ttl = 0 → hit:false', () => {
const key = researchKey({ ecosystem: 'npm', library: 'bad3', version: '1.0.0', query: 'q', kind: 'docs' });
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 0 });
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
assert.equal(result.hit, false, 'ttl=0 must return hit:false');
});
test('ttl = -1 (negative) → hit:false', () => {
const key = researchKey({ ecosystem: 'npm', library: 'bad4', version: '1.0.0', query: 'q', kind: 'docs' });
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: -1 });
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
assert.equal(result.hit, false, 'negative ttl must return hit:false');
});
test('fetched_at = "not-a-date" → hit:false', () => {
const key = researchKey({ ecosystem: 'npm', library: 'bad5', version: '1.0.0', query: 'q', kind: 'docs' });
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: 'not-a-date', ttl: 86400000 });
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
assert.equal(result.hit, false, 'invalid fetched_at must return hit:false');
});
test('valid entry still works', () => {
const key = researchKey({ ecosystem: 'npm', library: 'good', version: '1.0.0', query: 'q', kind: 'docs' });
const dir = path.join(tmpCwd, '.planning', 'research', '.cache');
writeEntry(dir, key, { content: 'valid', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 86400000 });
const result = getResearch(tmpCwd, key, { homeDir: tmpHome });
assert.equal(result.hit, true, 'valid entry should still hit');
});
});