374 lines
18 KiB
JavaScript
374 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* followup-seed-tests.mjs — regression tests for followup-seed.mjs (#1430).
|
|
*
|
|
* Marking a tracker row Applied used to leave data/follow-ups.md untouched
|
|
* until the user ran the `followup` mode by hand — the seed step never ran on
|
|
* its own. These tests drive followup-seed.mjs's CLI (via execFileSync, like
|
|
* tracker-columns-tests.mjs) end-to-end against sandboxed fixtures, plus a few
|
|
* direct unit-level imports of the exported functions.
|
|
*
|
|
* Run: node followup-seed-tests.mjs
|
|
*/
|
|
|
|
import { execFileSync } from 'child_process';
|
|
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
import { parseNextOverrides, resolveNextOverride, normalizeStatus, addDays, parseDate } from './followup-cadence.mjs';
|
|
|
|
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
const NODE = process.execPath;
|
|
const SCRIPT = join(ROOT, 'followup-seed.mjs');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
function pass(m) { console.log(`PASS ${m}`); passed++; }
|
|
function fail(m) { console.error(`FAIL ${m}`); failed++; }
|
|
|
|
function todayStr() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
// --- sandbox helpers --------------------------------------------------------
|
|
|
|
function makeSandbox() {
|
|
const dir = mkdtempSync(join(tmpdir(), 'co-seed-'));
|
|
const tracker = join(dir, 'applications.md');
|
|
const followups = join(dir, 'follow-ups.md');
|
|
const lock = join(dir, `career-ops-followups-test-${Math.random().toString(36).slice(2)}.lock`);
|
|
return { dir, tracker, followups, lock };
|
|
}
|
|
|
|
function writeTracker(sandbox, rows) {
|
|
const header = [
|
|
'# Applications Tracker',
|
|
'',
|
|
'| # | Date | Company | Role | Score | Status | PDF | Report | Notes |',
|
|
'|---|------|---------|------|-------|--------|-----|--------|-------|',
|
|
...rows,
|
|
'',
|
|
].join('\n');
|
|
writeFileSync(sandbox.tracker, header);
|
|
}
|
|
|
|
function trackerRow(num, date, company, role, score, status, notes) {
|
|
return `| ${num} | ${date} | ${company} | ${role} | ${score} | ${status} | ❌ | — | ${notes} |`;
|
|
}
|
|
|
|
// Run followup-seed.mjs against a sandbox. Returns { code, stdout, stderr }.
|
|
function run(args, sandbox, extraEnv = {}) {
|
|
const env = {
|
|
...process.env,
|
|
CAREER_OPS_TRACKER: sandbox.tracker,
|
|
CAREER_OPS_FOLLOWUPS: sandbox.followups,
|
|
CAREER_OPS_FOLLOWUPS_LOCK: sandbox.lock,
|
|
...extraEnv,
|
|
};
|
|
try {
|
|
const stdout = execFileSync(NODE, [SCRIPT, ...args], {
|
|
cwd: ROOT, env, encoding: 'utf-8', timeout: 30000,
|
|
});
|
|
return { code: 0, stdout, stderr: '' };
|
|
} catch (e) {
|
|
return { code: e.status ?? 1, stdout: e.stdout || '', stderr: e.stderr || '' };
|
|
}
|
|
}
|
|
|
|
function cleanup(sandbox) {
|
|
rmSync(sandbox.dir, { recursive: true, force: true });
|
|
rmSync(sandbox.lock, { recursive: true, force: true });
|
|
}
|
|
|
|
// ── Test 1: round-trip — pin date derives from notes, not the tracker date column ──
|
|
{
|
|
const sb = makeSandbox();
|
|
// Tracker date column (2026-05-01) is deliberately earlier than the notes'
|
|
// "Applied 2026-06-20" — the seed must use the notes date, never the column.
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20. Great team.')]);
|
|
|
|
const res = run(['1', '--json'], sb);
|
|
if (res.code !== 0) {
|
|
fail(`1. round-trip seed exits 0 (got ${res.code})\n${res.stdout}${res.stderr}`);
|
|
} else {
|
|
pass('1. round-trip seed exits 0');
|
|
const content = readFileSync(sb.followups, 'utf-8');
|
|
const overrides = parseNextOverrides(content);
|
|
const override = overrides.get(1);
|
|
if (override) {
|
|
pass('1. pin parses via parseNextOverrides');
|
|
const resolved = resolveNextOverride(override, null);
|
|
const expectedNext = addDays(parseDate('2026-06-20'), 7); // default applied_first
|
|
if (resolved === expectedNext) pass(`1. resolved next date is applied-date + applied_first (${expectedNext})`);
|
|
else fail(`1. resolved next date — expected ${expectedNext}, got ${resolved}`);
|
|
if (override.setDate === todayStr()) pass('1. setDate is today');
|
|
else fail(`1. setDate — expected ${todayStr()}, got ${override.setDate}`);
|
|
} else {
|
|
fail('1. pin parses via parseNextOverrides — no override found for #1');
|
|
}
|
|
}
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 2: seeded line matches OVERRIDE_RE, never starts with '|' ──────────
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.')]);
|
|
run(['1'], sb);
|
|
const content = readFileSync(sb.followups, 'utf-8');
|
|
const lines = content.split('\n').filter(l => l.trim().startsWith('- next'));
|
|
if (lines.length === 1 && /^-\s+next\s+#\d+\s+\d{4}-\d{2}-\d{2}(\s+\(set\s+\d{4}-\d{2}-\d{2}\))?\s*$/i.test(lines[0])) {
|
|
pass('2. seeded line matches OVERRIDE_RE');
|
|
} else {
|
|
fail(`2. seeded line matches OVERRIDE_RE — got: ${JSON.stringify(lines)}`);
|
|
}
|
|
if (!lines[0]?.startsWith('|')) pass('2. seeded line does not start with |');
|
|
else fail('2. seeded line does not start with |');
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 3: idempotency + --force appends a second pin (last wins) ─────────
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.')]);
|
|
const first = run(['1', '--json'], sb);
|
|
const second = run(['1', '--json'], sb);
|
|
if (second.code === 0 && JSON.parse(second.stdout).seeded === false) {
|
|
pass('3. second seed is a no-op (seeded:false)');
|
|
} else {
|
|
fail(`3. second seed is a no-op — code ${second.code}, stdout ${second.stdout}`);
|
|
}
|
|
|
|
// Pre-existing table row for the appNum should also block seeding.
|
|
const sb2 = makeSandbox();
|
|
writeTracker(sb2, [trackerRow(2, '2026-05-01', 'Globex', 'Manager', '4.0/5', 'Applied', 'Applied 2026-06-01.')]);
|
|
const tableHeader = [
|
|
'# Follow-ups',
|
|
'',
|
|
'| num | appNum | date | company | role | channel | contact | notes |',
|
|
'|---|---|---|---|---|---|---|---|',
|
|
'| 1 | 2 | 2026-06-10 | Globex | Manager | email | recruiter@globex.com | sent |',
|
|
'',
|
|
].join('\n');
|
|
writeFileSync(sb2.followups, tableHeader);
|
|
const blocked = run(['2', '--json'], sb2);
|
|
if (blocked.code === 0 && JSON.parse(blocked.stdout).seeded === false) {
|
|
pass('3. pre-existing table row blocks seeding');
|
|
} else {
|
|
fail(`3. pre-existing table row blocks seeding — code ${blocked.code}, stdout ${blocked.stdout}`);
|
|
}
|
|
cleanup(sb2);
|
|
|
|
const forced = run(['1', '--force', '--json'], sb);
|
|
if (forced.code === 0 && JSON.parse(forced.stdout).seeded === true) {
|
|
pass('3. --force appends a fresh pin');
|
|
} else {
|
|
fail(`3. --force appends a fresh pin — code ${forced.code}, stdout ${forced.stdout}`);
|
|
}
|
|
const content = readFileSync(sb.followups, 'utf-8');
|
|
const overrides = parseNextOverrides(content);
|
|
const pinLines = content.split('\n').filter(l => l.trim().startsWith('- next #1 '));
|
|
if (pinLines.length === 2) pass('3. two pin lines now present for #1');
|
|
else fail(`3. two pin lines now present for #1 — got ${pinLines.length}`);
|
|
// parseNextOverrides keeps the LAST pin per app.
|
|
const lastPin = pinLines[pinLines.length - 1];
|
|
const lastDateMatch = lastPin.match(/#1\s+(\d{4}-\d{2}-\d{2})/);
|
|
if (overrides.get(1)?.date === lastDateMatch?.[1]) pass('3. parseNextOverrides returns the LAST pin');
|
|
else fail(`3. parseNextOverrides returns the LAST pin — got ${JSON.stringify(overrides.get(1))} vs last line ${lastPin}`);
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 4: date resolution order — --date beats notes; notes beat today; column never used ──
|
|
{
|
|
const sb = makeSandbox();
|
|
// Column date is 2026-01-01 (would be very wrong if ever used).
|
|
writeTracker(sb, [trackerRow(1, '2026-01-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.')]);
|
|
const withDate = run(['1', '--date', '2026-06-25', '--json'], sb);
|
|
const parsed = JSON.parse(withDate.stdout);
|
|
if (parsed.appliedDate === '2026-06-25') pass('4. --date beats notes date');
|
|
else fail(`4. --date beats notes date — got ${parsed.appliedDate}`);
|
|
if (parsed.appliedDate !== '2026-01-01') pass('4. tracker date column never used');
|
|
else fail('4. tracker date column never used — got column date');
|
|
cleanup(sb);
|
|
|
|
// Unit-level: resolveAppliedDate directly.
|
|
const mod = await import(pathToFileURL(SCRIPT).href);
|
|
const rowWithNotes = { notes: 'Applied 2026-06-20. Some other text.' };
|
|
if (mod.resolveAppliedDate(rowWithNotes, null) === '2026-06-20') pass('4. resolveAppliedDate: notes beat today (no explicit date)');
|
|
else fail('4. resolveAppliedDate: notes beat today');
|
|
if (mod.resolveAppliedDate(rowWithNotes, '2026-07-01') === '2026-07-01') pass('4. resolveAppliedDate: explicit date wins over notes');
|
|
else fail('4. resolveAppliedDate: explicit date wins over notes');
|
|
const rowNoNotes = { notes: 'no date here' };
|
|
if (mod.resolveAppliedDate(rowNoNotes, null) === todayStr()) pass('4. resolveAppliedDate: falls back to today');
|
|
else fail('4. resolveAppliedDate: falls back to today');
|
|
}
|
|
|
|
// ── Test 5: missing file gets exact canonical header; append preserves bytes ──
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.')]);
|
|
run(['1'], sb);
|
|
const content = readFileSync(sb.followups, 'utf-8');
|
|
const expectedHeader = [
|
|
'# Follow-ups',
|
|
'',
|
|
'| num | appNum | date | company | role | channel | contact | notes |',
|
|
'|---|---|---|---|---|---|---|---|',
|
|
].join('\n');
|
|
if (content.startsWith(expectedHeader)) pass('5. missing file created with exact canonical header');
|
|
else fail(`5. missing file created with exact canonical header — got:\n${content.slice(0, 200)}`);
|
|
|
|
// Now append a second app; prior bytes (header + first pin) must be preserved exactly.
|
|
const before = readFileSync(sb.followups, 'utf-8');
|
|
writeTracker(sb, [
|
|
trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.'),
|
|
trackerRow(2, '2026-05-01', 'Globex', 'Manager', '4.0/5', 'Applied', 'Applied 2026-06-15.'),
|
|
]);
|
|
run(['2'], sb);
|
|
const after = readFileSync(sb.followups, 'utf-8');
|
|
if (after.startsWith(before)) pass('5. appending to existing file preserves prior bytes exactly');
|
|
else fail('5. appending to existing file preserves prior bytes exactly');
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 6: impossible calendar date rejected ───────────────────────────────
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.')]);
|
|
const res = run(['1', '--date', '2026-02-31'], sb);
|
|
if (res.code === 1) pass('6. --date 2026-02-31 exits 1');
|
|
else fail(`6. --date 2026-02-31 exits 1 — got ${res.code}`);
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 7: non-Applied row rejected without --force, accepted with --force ──
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Evaluated', 'no applied date yet')]);
|
|
const res = run(['1'], sb);
|
|
if (res.code === 1) pass('7. non-Applied row rejected without --force (exit 1)');
|
|
else fail(`7. non-Applied row rejected without --force — got ${res.code}`);
|
|
const forced = run(['1', '--force', '--json'], sb);
|
|
if (forced.code === 0 && JSON.parse(forced.stdout).seeded === true) pass('7. non-Applied row succeeds with --force');
|
|
else fail(`7. non-Applied row succeeds with --force — code ${forced.code}, stdout ${forced.stdout}`);
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 8: --backfill seeds only unpinned Applied rows; re-run seeds 0 ────
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [
|
|
trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-01.'), // Applied, unpinned
|
|
trackerRow(2, '2026-05-01', 'Globex', 'Manager', '4.0/5', 'Applied', 'Applied 2026-06-05.'), // Applied, will be pre-pinned
|
|
trackerRow(3, '2026-05-01', 'Initech', 'Analyst', '3.0/5', 'Evaluated', 'not applied'), // Evaluated
|
|
trackerRow(4, '2026-05-01', 'Umbrella', 'Scientist', '2.0/5', 'Rejected', 'Applied 2026-04-01. rejected'), // Rejected
|
|
]);
|
|
// Pre-seed #2 with a pin so backfill must skip it.
|
|
run(['2'], sb);
|
|
|
|
const res = run(['--backfill', '--json'], sb);
|
|
if (res.code !== 0) fail(`8. --backfill exits 0 (got ${res.code})\n${res.stdout}${res.stderr}`);
|
|
else {
|
|
const parsed = JSON.parse(res.stdout);
|
|
if (parsed.seeded.length === 1 && parsed.seeded[0].appNum === 1) pass('8. --backfill seeds exactly the 1 unpinned Applied row');
|
|
else fail(`8. --backfill seeds exactly the 1 unpinned Applied row — got ${JSON.stringify(parsed.seeded)}`);
|
|
if (parsed.skipped.some(s => s.appNum === 2)) pass('8. --backfill skips the already-pinned Applied row');
|
|
else fail(`8. --backfill skips the already-pinned Applied row — got ${JSON.stringify(parsed.skipped)}`);
|
|
}
|
|
|
|
const rerun = run(['--backfill', '--json'], sb);
|
|
const rerunParsed = JSON.parse(rerun.stdout);
|
|
if (rerun.code === 0 && rerunParsed.seeded.length === 0) pass('8. re-run backfill seeds 0');
|
|
else fail(`8. re-run backfill seeds 0 — got ${JSON.stringify(rerunParsed)}`);
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 9: lock held by a live pid → exit 4 ────────────────────────────────
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.')]);
|
|
mkdirSync(sb.lock, { recursive: true });
|
|
writeFileSync(join(sb.lock, 'owner.json'), JSON.stringify({ pid: process.pid, token: 'x', startedAt: new Date().toISOString() }));
|
|
const res = run(['1'], sb, { CAREER_OPS_FOLLOWUPS_LOCK_TIMEOUT_MS: '200' });
|
|
if (res.code === 4) pass('9. lock held by live pid → exit 4');
|
|
else fail(`9. lock held by live pid → exit 4 — got ${res.code}\n${res.stdout}${res.stderr}`);
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 10: stale-pin harmlessness — pin still parses after status flips to Rejected ──
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.')]);
|
|
run(['1'], sb);
|
|
// Flip status to Rejected — the pin is inert (cadence analysis only consumes
|
|
// actionable statuses), but the pin line itself must still parse fine.
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Rejected', 'Applied 2026-06-20. rejected after onsite')]);
|
|
const content = readFileSync(sb.followups, 'utf-8');
|
|
const overrides = parseNextOverrides(content);
|
|
if (overrides.has(1)) pass('10. stale pin still parses via parseNextOverrides');
|
|
else fail('10. stale pin still parses via parseNextOverrides');
|
|
if (normalizeStatus('Rejected') === 'rejected') pass('10. normalizeStatus(Rejected) === rejected (not actionable)');
|
|
else fail(`10. normalizeStatus(Rejected) — got ${normalizeStatus('Rejected')}`);
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 11: impossible "Applied" date in notes → INVALID_DATE, no garbage pin ──
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-02-31. Bad date.')]);
|
|
const res = run(['1', '--json'], sb);
|
|
if (res.code === 1) pass('11. impossible notes date → exit 1');
|
|
else fail(`11. impossible notes date → exit 1 — got ${res.code}\n${res.stdout}${res.stderr}`);
|
|
if ((res.stdout + res.stderr).includes('impossible')) pass('11. error names the invalid date problem');
|
|
else fail(`11. error names the invalid date problem — got\n${res.stdout}${res.stderr}`);
|
|
// An explicit valid --date must rescue the row (notes date is skipped entirely).
|
|
const rescued = run(['1', '--date', '2026-06-20', '--json'], sb);
|
|
let rescuedOk = false;
|
|
try { rescuedOk = rescued.code === 0 && JSON.parse(rescued.stdout).seeded === true; } catch { /* fall through */ }
|
|
if (rescuedOk) pass('11. explicit --date rescues a row with bad notes date');
|
|
else fail(`11. explicit --date rescues — got code ${rescued.code}\n${rescued.stdout}${rescued.stderr}`);
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 12: backfill skips (not aborts on) a row with an impossible notes date ──
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [
|
|
trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-02-31. Bad date.'),
|
|
trackerRow(2, '2026-05-02', 'Globex', 'Engineer', '4.2/5', 'Applied', 'Applied 2026-06-20.'),
|
|
]);
|
|
const res = run(['--backfill', '--json'], sb);
|
|
if (res.code === 0) pass('12. backfill with one bad-notes row exits 0');
|
|
else fail(`12. backfill with one bad-notes row exits 0 — got ${res.code}\n${res.stdout}${res.stderr}`);
|
|
try {
|
|
const out = JSON.parse(res.stdout);
|
|
if (out.seeded.length === 1 && out.seeded[0].appNum === 2) pass('12. good row still seeded');
|
|
else fail(`12. good row still seeded — got ${JSON.stringify(out.seeded)}`);
|
|
if (out.skipped.length === 1 && out.skipped[0].appNum === 1 && out.skipped[0].reason === 'invalid-notes-date') {
|
|
pass('12. bad row skipped with reason invalid-notes-date');
|
|
} else {
|
|
fail(`12. bad row skipped with reason invalid-notes-date — got ${JSON.stringify(out.skipped)}`);
|
|
}
|
|
} catch (e) {
|
|
fail(`12. backfill JSON parses — ${e.message}\n${res.stdout}`);
|
|
}
|
|
cleanup(sb);
|
|
}
|
|
|
|
// ── Test 13: --date combined with --backfill is a usage error ──
|
|
{
|
|
const sb = makeSandbox();
|
|
writeTracker(sb, [trackerRow(1, '2026-05-01', 'Acme', 'Engineer', '4.0/5', 'Applied', 'Applied 2026-06-20.')]);
|
|
const res = run(['--backfill', '--date', '2026-06-20'], sb);
|
|
if (res.code === 1) pass('13. --backfill --date → exit 1');
|
|
else fail(`13. --backfill --date → exit 1 — got ${res.code}\n${res.stdout}${res.stderr}`);
|
|
if (res.stderr.includes('--date cannot be combined with --backfill')) pass('13. usage error explains the rejection');
|
|
else fail(`13. usage error explains the rejection — got\n${res.stderr}`);
|
|
cleanup(sb);
|
|
}
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed > 0 ? 1 : 0);
|