Files
santifer--career-ops/detect-reposts.test.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

882 lines
44 KiB
JavaScript

/**
* detect-reposts.test.mjs — Systematic test suite for detect-reposts.mjs
*
* Tests every exported and internal function across:
* - Input validation (malformed, missing, boundary values)
* - Date handling (calendar validity, format, edge dates)
* - TSV parsing (column count, whitespace, special chars)
* - Row filtering (status, empty fields, type coercion)
* - Company grouping (case sensitivity, unicode, whitespace)
* - Title matching (exact, fuzzy, variations, empty, unicode)
* - Window logic (boundary days, transitive chains, sliding window)
* - URL deduplication (same URL, multiple sightings, dedup + repost)
* - Cluster output shape (all fields present, correct types)
* - CLI behavior (args, flags, missing file)
* - Integration (full TSV round-trip, multi-company, mixed statuses)
*
* Run: node detect-reposts.test.mjs
*/
import { detectReposts, parseScanHistory } from './detect-reposts.mjs';
import { roleFuzzyMatch } from './role-matcher.mjs';
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdtempSync, rmSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
let passed = 0;
let failed = 0;
const failures = [];
function ok(label, cond) {
if (cond) {
passed++;
} else {
failed++;
failures.push(label);
console.log(` FAIL: ${label}`);
}
}
function eq(label, actual, expected) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a === e) {
passed++;
} else {
failed++;
failures.push(label);
console.log(` FAIL: ${label}`);
console.log(` expected: ${e}`);
console.log(` actual: ${a}`);
}
}
// Helper: create a row object with sensible defaults
function row(overrides) {
return {
url: 'https://example.com/jobs/1',
date: new Date('2026-01-01T00:00:00Z'),
dateStr: '2026-01-01',
title: 'Backend Engineer Platform',
company: 'Acme',
status: 'added',
portal: 'greenhouse',
location: 'Remote',
...overrides,
};
}
// Helper: create a date from ISO string
function d(iso) {
return new Date(iso + 'T00:00:00Z');
}
// ============================================================================
// 1. parseDate (replicated for testing — internal function not exported)
// ============================================================================
console.log('\n--- 1. parseDate ---');
function parseDate(dateStr) {
const iso = String(dateStr || '').trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
const date = new Date(`${iso}T00:00:00Z`);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== iso) return null;
return date;
}
// Valid dates
ok('valid date 2026-01-01', parseDate('2026-01-01') !== null);
ok('valid date 2026-12-31', parseDate('2026-12-31') !== null);
ok('valid date 2024-02-29 (leap year)', parseDate('2024-02-29') !== null);
ok('valid date 2026-06-15', parseDate('2026-06-15') !== null);
ok('returns Date object for valid input', parseDate('2026-01-01') instanceof Date);
// Calendar-invalid dates (the CodeRabbit bug)
eq('2024-02-31 rejected (rolls to March)', parseDate('2024-02-31'), null);
eq('2024-13-01 rejected (month 13)', parseDate('2024-13-01'), null);
eq('2024-00-01 rejected (month 0)', parseDate('2024-00-01'), null);
eq('2024-01-00 rejected (day 0)', parseDate('2024-01-00'), null);
eq('2024-01-32 rejected (day 32)', parseDate('2024-01-32'), null);
eq('2023-02-29 rejected (not leap year)', parseDate('2023-02-29'), null);
eq('2024-02-30 rejected (Feb 30 never exists)', parseDate('2024-02-30'), null);
// Format validation
eq('null rejected', parseDate(null), null);
eq('undefined rejected', parseDate(undefined), null);
eq('empty string rejected', parseDate(''), null);
eq('whitespace-only rejected', parseDate(' '), null);
eq('wrong format (slashes) rejected', parseDate('2026/01/01'), null);
eq('wrong format (dots) rejected', parseDate('2026.01.01'), null);
eq('wrong format (no dashes) rejected', parseDate('20260101'), null);
eq('partial date rejected', parseDate('2026-01'), null);
eq('extra characters rejected', parseDate('2026-01-01T00:00:00Z'), null);
eq('negative year rejected', parseDate('-001-01-01'), null);
eq('5-digit year rejected', parseDate('10000-01-01'), null);
eq('2-digit year rejected', parseDate('26-01-01'), null);
eq('single digit month rejected', parseDate('2026-1-01'), null);
eq('single digit day rejected', parseDate('2026-01-1'), null);
// Whitespace handling
ok('whitespace trimmed (valid)', parseDate(' 2026-01-01 ') !== null);
ok('tab trimmed (valid)', parseDate('\t2026-01-01\t') !== null);
// Type coercion
ok('number coerced to string (20260101 -> not valid format)', parseDate(20260101) === null);
ok('object coerced to string', parseDate({}) === null);
// Round-trip: parsed date toISOString should match input
const rt = parseDate('2026-06-15');
eq('round-trip toISOString matches input', rt.toISOString().slice(0, 10), '2026-06-15');
// ============================================================================
// 2. daysBetween
// ============================================================================
console.log('\n--- 2. daysBetween ---');
function daysBetween(d1, d2) {
return Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
}
eq('same day = 0', daysBetween(d('2026-01-01'), d('2026-01-01')), 0);
eq('1 day apart', daysBetween(d('2026-01-01'), d('2026-01-02')), 1);
eq('30 days apart', daysBetween(d('2026-01-01'), d('2026-01-31')), 30);
eq('365 days apart', daysBetween(d('2026-01-01'), d('2027-01-01')), 365);
eq('negative (reverse order)', daysBetween(d('2026-01-02'), d('2026-01-01')), -1);
eq('crosses month boundary', daysBetween(d('2026-01-31'), d('2026-02-01')), 1);
eq('crosses year boundary', daysBetween(d('2026-12-31'), d('2027-01-01')), 1);
eq('leap year Feb 29 exists', daysBetween(d('2024-02-28'), d('2024-02-29')), 1);
eq('non-leap year Feb 28 to Mar 1', daysBetween(d('2023-02-28'), d('2023-03-01')), 1);
// DST handling: both dates use UTC (T00:00:00Z), so no DST drift
eq('DST spring forward (Mar 9-10 2026)', daysBetween(d('2026-03-08'), d('2026-03-09')), 1);
eq('DST fall back (Nov 1-2 2026)', daysBetween(d('2026-11-01'), d('2026-11-02')), 1);
// ============================================================================
// 3. detectReposts — input validation
// ============================================================================
console.log('\n--- 3. detectReposts input validation ---');
eq('null input -> empty', detectReposts(null), []);
eq('undefined input -> empty', detectReposts(undefined), []);
eq('empty array -> empty', detectReposts([]), []);
eq('single row -> empty', detectReposts([row()]), []);
eq('two rows same company different role -> empty (no match)', detectReposts([
row({ url: 'https://x.com/1', title: 'Backend Engineer Platform' }),
row({ url: 'https://x.com/2', title: 'Product Marketing Manager Senior' }),
]), []);
// Status filtering
eq('skipped_expired rows ignored', detectReposts([
row({ url: 'https://x.com/1', status: 'skipped_expired' }),
row({ url: 'https://x.com/2', status: 'skipped_expired', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]), []);
eq('skipped_invalid_url rows ignored', detectReposts([
row({ url: 'https://x.com/1', status: 'skipped_invalid_url' }),
row({ url: 'https://x.com/2', status: 'skipped_invalid_url', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]), []);
eq('skipped_blocked_host rows ignored', detectReposts([
row({ url: 'https://x.com/1', status: 'skipped_blocked_host' }),
row({ url: 'https://x.com/2', status: 'skipped_blocked_host', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]), []);
eq('mixed status: only added rows used', detectReposts([
row({ url: 'https://x.com/1', status: 'added' }),
row({ url: 'https://x.com/2', status: 'skipped_expired', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
// Empty field filtering (CodeRabbit fix 2)
eq('empty url -> filtered', detectReposts([
row({ url: '' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('null url -> filtered', detectReposts([
row({ url: null }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('whitespace-only url -> filtered', detectReposts([
row({ url: ' ' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('empty company -> filtered (no cross-company false positive)', detectReposts([
row({ url: 'https://a.com/1', company: '' }),
row({ url: 'https://b.com/2', company: '', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('whitespace-only company -> filtered', detectReposts([
row({ url: 'https://a.com/1', company: ' ' }),
row({ url: 'https://b.com/2', company: ' ', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('empty title -> filtered', detectReposts([
row({ url: 'https://a.com/1', title: '' }),
row({ url: 'https://a.com/2', title: '', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('whitespace-only title -> filtered', detectReposts([
row({ url: 'https://a.com/1', title: ' ' }),
row({ url: 'https://a.com/2', title: ' ', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('null date -> filtered', detectReposts([
row({ url: 'https://a.com/1', date: null }),
row({ url: 'https://a.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
// CodeRabbit: type coercion and edge cases
eq('null row in array -> no crash', detectReposts([
null,
row({ url: 'https://a.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('undefined row in array -> no crash', detectReposts([
undefined,
row({ url: 'https://a.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('non-string company (number 123) -> filtered, no crash', detectReposts([
row({ url: 'https://a.com/1', company: 123 }),
row({ url: 'https://a.com/2', company: 123, date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('non-string title (number) -> filtered, no crash', detectReposts([
row({ url: 'https://a.com/1', title: 123 }),
row({ url: 'https://a.com/2', title: 123, date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('string date (not Date object) -> filtered, no crash', detectReposts([
row({ url: 'https://a.com/1', date: '2026-01-01' }),
row({ url: 'https://a.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('invalid Date object -> filtered, no crash', detectReposts([
row({ url: 'https://a.com/1', date: new Date('invalid') }),
row({ url: 'https://a.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('null row object -> filtered', detectReposts([
row({ url: 'https://a.com/1' }),
null,
row({ url: 'https://a.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
// ============================================================================
// 4. detectReposts — company grouping
// ============================================================================
console.log('\n--- 4. company grouping ---');
eq('case insensitive: ACME == acme == Acme', detectReposts([
row({ url: 'https://a.com/1', company: 'ACME', title: 'Backend Engineer Platform' }),
row({ url: 'https://a.com/2', company: 'acme', date: d('2026-02-01'), dateStr: '2026-02-01' }),
row({ url: 'https://a.com/3', company: 'Acme', date: d('2026-03-01'), dateStr: '2026-03-01' }),
]).length, 1);
eq('different companies not grouped', detectReposts([
row({ url: 'https://a.com/1', company: 'Acme', title: 'Backend Engineer Platform' }),
row({ url: 'https://b.com/2', company: 'Beta', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('unicode company names grouped correctly', detectReposts([
row({ url: 'https://t.com/1', company: 'Тинькофф', title: 'Backend Engineer Platform' }),
row({ url: 'https://t.com/2', company: 'Тинькофф', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
eq('company with trailing whitespace trimmed', detectReposts([
row({ url: 'https://a.com/1', company: 'Acme ', title: 'Backend Engineer Platform' }),
row({ url: 'https://a.com/2', company: ' Acme', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
// ============================================================================
// 5. detectReposts — title matching
// ============================================================================
console.log('\n--- 5. title matching ---');
eq('identical titles match', detectReposts([
row({ url: 'https://x.com/1', title: 'Senior Backend Engineer Payments' }),
row({ url: 'https://x.com/2', title: 'Senior Backend Engineer Payments', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
eq('fuzzy match: word order variation', detectReposts([
row({ url: 'https://x.com/1', title: 'Senior Backend Engineer Payments' }),
row({ url: 'https://x.com/2', title: 'Backend Engineer Payments Senior', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
eq('fuzzy match: minor wording change', detectReposts([
row({ url: 'https://x.com/1', title: 'Senior Backend Engineer Payments Processing' }),
row({ url: 'https://x.com/2', title: 'Backend Engineer Payments Processing Senior', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
eq('distinct roles NOT matched', detectReposts([
row({ url: 'https://x.com/1', title: 'Senior Backend Engineer Payments' }),
row({ url: 'https://x.com/2', title: 'Engineering Manager Platform Infrastructure', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
eq('short identical titles match (fast path)', detectReposts([
row({ url: 'https://x.com/1', title: 'Senior ML Engineer' }),
row({ url: 'https://x.com/2', title: 'Senior ML Engineer', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
eq('titles differing only in case match', detectReposts([
row({ url: 'https://x.com/1', title: 'Senior Backend Engineer' }),
row({ url: 'https://x.com/2', title: 'senior BACKEND engineer', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
eq('special chars in titles (C++ / Rust)', detectReposts([
row({ url: 'https://x.com/1', title: 'C++ / Rust Systems Engineer' }),
row({ url: 'https://x.com/2', title: 'C++ / Rust Systems Engineer', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
eq('unicode titles (Cyrillic)', detectReposts([
row({ url: 'https://x.com/1', title: 'Инженер-программист Backend', company: 'Тинькофф' }),
row({ url: 'https://x.com/2', title: 'Инженер-программист Backend', company: 'Тинькофф', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
// ============================================================================
// 6. detectReposts — window logic (boundary conditions)
// ============================================================================
console.log('\n--- 6. window logic ---');
// Exact boundary: 90 days should be included
eq('exactly 90 days -> flagged', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-04-01'), dateStr: '2026-04-01' }),
], 90).length, 1);
// 91 days should be excluded
eq('91 days -> NOT flagged', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-04-02'), dateStr: '2026-04-02' }),
], 90).length, 0);
// Window = 0: same-day only
eq('window=0, same day, different URL -> flagged', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-01-01'), dateStr: '2026-01-01' }),
], 0).length, 1);
eq('window=0, 1 day apart -> NOT flagged', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-01-02'), dateStr: '2026-01-02' }),
], 0).length, 0);
// Window = 1: 1 day apart
eq('window=1, 1 day apart -> flagged', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-01-02'), dateStr: '2026-01-02' }),
], 1).length, 1);
// Negative window (invalid) — should not crash
ok('negative window does not crash', detectReposts([
row({ url: 'https://x.com/1' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
], -1).length === 0);
// Very large window
eq('window=36500 (100 years) -> flagged', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2027-01-01'), dateStr: '2027-01-01' }),
], 36500).length, 1);
// Transitive chain (CodeRabbit fix 3): A-B within window, B-C within window, A-C exceeds
const transResult = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-03-01'), dateStr: '2026-03-01' }),
row({ url: 'https://x.com/3', date: d('2026-05-01'), dateStr: '2026-05-01' }),
], 90);
ok('transitive chain: at least 2 clusters found (both pairs detected)', transResult.length >= 2);
if (transResult.length >= 2) {
// Clusters sorted by lastSeen descending: Mar 1-May 1 (61d) first, Jan 1-Mar 1 (59d) second
eq('transitive chain: first cluster span = 61d (Mar 1 - May 1)', transResult[0].daysSpan, 61);
eq('transitive chain: second cluster span = 59d (Jan 1 - Mar 1)', transResult[1].daysSpan, 59);
}
// 3 reposts all within window
const threeResult = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
row({ url: 'https://x.com/3', date: d('2026-03-01'), dateStr: '2026-03-01' }),
], 90);
eq('3 reposts within window -> 1 cluster, 3 appearances', threeResult.length, 1);
eq('3 reposts: repostCount = 3', threeResult[0]?.repostCount, 3);
eq('3 reposts: daysSpan = 59', threeResult[0]?.daysSpan, 59);
eq('3 reposts: firstSeen = 2026-01-01', threeResult[0]?.firstSeen, '2026-01-01');
eq('3 reposts: lastSeen = 2026-03-01', threeResult[0]?.lastSeen, '2026-03-01');
// 4 reposts spanning exactly 90 days
const fourResult = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-01-31'), dateStr: '2026-01-31' }),
row({ url: 'https://x.com/3', date: d('2026-03-01'), dateStr: '2026-03-01' }),
row({ url: 'https://x.com/4', date: d('2026-04-01'), dateStr: '2026-04-01' }),
], 90);
eq('4 reposts within 90d -> 1 cluster', fourResult.length, 1);
eq('4 reposts: repostCount = 4', fourResult[0]?.repostCount, 4);
eq('4 reposts: daysSpan = 90', fourResult[0]?.daysSpan, 90);
// 4 reposts where last one is 91 days from first (should split)
const splitResult = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
row({ url: 'https://x.com/3', date: d('2026-03-01'), dateStr: '2026-03-01' }),
row({ url: 'https://x.com/4', date: d('2026-04-02'), dateStr: '2026-04-02' }),
], 90);
ok('4 reposts split: at least 1 cluster (first 3 within window)', splitResult.length >= 1);
if (splitResult.length >= 1) {
eq('split: first cluster has 3 reposts', splitResult[0].repostCount, 3);
}
// Sliding window: overlapping repost pairs (CodeRabbit's exact scenario)
// Jan 1 + Mar 15 = 73d (within 90), Mar 15 + Jun 10 = 87d (within 90),
// but Jan 1 + Jun 10 = 160d (exceeds 90). Both pairs must be detected.
const slidingResult = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-03-15'), dateStr: '2026-03-15' }),
row({ url: 'https://x.com/3', date: d('2026-06-10'), dateStr: '2026-06-10' }),
], 90);
eq('sliding window: 2 clusters (both overlapping pairs detected)', slidingResult.length, 2);
if (slidingResult.length === 2) {
// Sorted by lastSeen descending: Jun 10 cluster first, Mar 15 cluster second
eq('sliding window: cluster 1 is Mar 15-Jun 10 (87d)', slidingResult[0].daysSpan, 87);
eq('sliding window: cluster 2 is Jan 1-Mar 15 (73d)', slidingResult[1].daysSpan, 73);
}
// Dates not in chronological order in input
const unsortedResult = detectReposts([
row({ url: 'https://x.com/2', date: d('2026-03-01'), dateStr: '2026-03-01' }),
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/3', date: d('2026-02-01'), dateStr: '2026-02-01' }),
], 90);
eq('unsorted input dates -> still works (sorted internally)', unsortedResult.length, 1);
eq('unsorted: firstSeen = 2026-01-01', unsortedResult[0]?.firstSeen, '2026-01-01');
eq('unsorted: lastSeen = 2026-03-01', unsortedResult[0]?.lastSeen, '2026-03-01');
// Same date, different URLs
eq('same date, different URLs -> flagged', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-01-01'), dateStr: '2026-01-01' }),
], 90).length, 1);
// ============================================================================
// 7. URL deduplication
// ============================================================================
console.log('\n--- 7. URL deduplication ---');
// Same URL seen twice = dedup hit, not a repost
eq('same URL twice -> no cluster', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/1', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 0);
// Same URL + different URL = 2 appearances (same URL deduped, different URL counts)
const mixedResult = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/1', date: d('2026-02-01'), dateStr: '2026-02-01' }),
row({ url: 'https://x.com/2', date: d('2026-03-01'), dateStr: '2026-03-01' }),
], 90);
eq('same URL seen twice + 1 different URL -> 1 cluster', mixedResult.length, 1);
eq('deduped to 2 appearances (not 3)', mixedResult[0]?.repostCount, 2);
// Same URL with trailing slash vs without — treated as different URLs
// (this is correct: URL normalization is scan.mjs's job, not ours)
eq('URL with/without trailing slash treated as different', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/1/', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
// URL with different fragments
eq('URL with different fragments treated as different', detectReposts([
row({ url: 'https://x.com/1#section', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/1#other', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
// URL with query params
eq('URL with different query params treated as different', detectReposts([
row({ url: 'https://x.com/1?ref=linkedin', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/1?ref=google', date: d('2026-02-01'), dateStr: '2026-02-01' }),
]).length, 1);
// ============================================================================
// 8. Cluster output shape
// ============================================================================
console.log('\n--- 8. cluster output shape ---');
const shapeResult = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01', title: 'Backend Engineer Platform', company: 'Acme' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01', title: 'Backend Engineer Platform', company: 'Acme' }),
], 90);
ok('returns array', Array.isArray(shapeResult));
ok('cluster is object', shapeResult.length === 1 && typeof shapeResult[0] === 'object');
const c = shapeResult[0];
ok('has company field', 'company' in c);
ok('has role field', 'role' in c);
ok('has repostCount field', 'repostCount' in c);
ok('has firstSeen field', 'firstSeen' in c);
ok('has lastSeen field', 'lastSeen' in c);
ok('has daysSpan field', 'daysSpan' in c);
ok('has appearances field', 'appearances' in c);
ok('company is string', typeof c.company === 'string');
ok('role is string', typeof c.role === 'string');
ok('repostCount is number', typeof c.repostCount === 'number');
ok('firstSeen is string (ISO date)', typeof c.firstSeen === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(c.firstSeen));
ok('lastSeen is string (ISO date)', typeof c.lastSeen === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(c.lastSeen));
ok('daysSpan is number', typeof c.daysSpan === 'number');
ok('appearances is array', Array.isArray(c.appearances));
ok('appearance has url', 'url' in c.appearances[0]);
ok('appearance has date', 'date' in c.appearances[0]);
ok('appearance has title', 'title' in c.appearances[0]);
ok('appearance url is string', typeof c.appearances[0].url === 'string');
ok('appearance date is string (ISO date)', typeof c.appearances[0].date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(c.appearances[0].date));
ok('appearance title is string', typeof c.appearances[0].title === 'string');
// Role uses most recent title (last by first_seen)
// Using identical titles to test that the most recent title wording is used
const shapeResult2 = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01', title: 'Backend Engineer Platform' }),
row({ url: 'https://x.com/2', date: d('2026-02-15'), dateStr: '2026-02-15', title: 'backend engineer PLATFORM' }),
], 90);
ok('case-insensitive title match produces cluster', shapeResult2.length === 1);
if (shapeResult2.length === 1) {
eq('role uses most recent title', shapeResult2[0].role, 'backend engineer PLATFORM');
}
// Company uses the company from the rows (not lowercased)
eq('company preserves original case in output', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01', company: 'ACME Corp' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01', company: 'acme corp' }),
], 90)[0].company, 'ACME Corp');
// Appearances sorted by date ascending
const appResult = detectReposts([
row({ url: 'https://x.com/3', date: d('2026-03-01'), dateStr: '2026-03-01' }),
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
], 90)[0];
eq('appearances sorted ascending by date', appResult.appearances.map(a => a.date), ['2026-01-01', '2026-02-01', '2026-03-01']);
// Clusters sorted by lastSeen descending (most recent first)
const sortResult = detectReposts([
row({ url: 'https://a.com/1', date: d('2026-01-01'), dateStr: '2026-01-01', title: 'Backend Engineer Platform', company: 'Alpha' }),
row({ url: 'https://a.com/2', date: d('2026-02-01'), dateStr: '2026-02-01', title: 'Backend Engineer Platform', company: 'Alpha' }),
row({ url: 'https://b.com/1', date: d('2026-03-01'), dateStr: '2026-03-01', title: 'Backend Engineer Platform', company: 'Beta' }),
row({ url: 'https://b.com/2', date: d('2026-04-01'), dateStr: '2026-04-01', title: 'Backend Engineer Platform', company: 'Beta' }),
], 90);
eq('multiple companies: Beta cluster first (more recent lastSeen)', sortResult[0].company, 'Beta');
eq('multiple companies: Alpha cluster second', sortResult[1].company, 'Alpha');
// ============================================================================
// 9. TSV parsing (integration with parseScanHistory)
// ============================================================================
console.log('\n--- 9. TSV parsing ---');
// The parseScanHistory function is internal, but we can test it through loadScanHistory
// by creating temp files. We test the full pipeline: TSV -> parseScanHistory -> detectReposts.
function makeTsv(rows) {
const header = 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\tlocation';
const lines = rows.map(r =>
[r.url, r.dateStr, r.portal || 'greenhouse', r.title, r.company, r.status || 'added', r.location || ''].join('\t')
);
return [header, ...lines].join('\n');
}
// We can't call parseScanHistory directly (not exported), but we can test the CLI
// output by running the script against a temp TSV. For now, test via detectReposts
// with row objects that match what parseScanHistory would produce.
// Simulate TSV parsing: minimal valid TSV
const tsvContent = makeTsv([
{ url: 'https://a.com/1', dateStr: '2026-01-01', title: 'Backend Engineer Platform', company: 'Acme', status: 'added' },
{ url: 'https://a.com/2', dateStr: '2026-02-01', title: 'Backend Engineer Platform', company: 'Acme', status: 'added' },
{ url: 'https://a.com/3', dateStr: '2026-03-01', title: 'Product Manager Senior', company: 'Acme', status: 'added' },
]);
// Write to temp file and test via the script
const tmpDir = mkdtempSync(join(tmpdir(), 'detect-reposts-test-'));
const tmpTsv = join(tmpDir, 'scan-history.tsv');
writeFileSync(tmpTsv, tsvContent);
try {
// Parse using the production parser
const parsedRows = parseScanHistory(tsvContent);
const tsvResult = detectReposts(parsedRows, 90);
eq('TSV round-trip: 1 cluster found', tsvResult.length, 1);
eq('TSV round-trip: cluster is Backend Engineer Platform', tsvResult[0]?.role, 'Backend Engineer Platform');
eq('TSV round-trip: 2 reposts', tsvResult[0]?.repostCount, 2);
eq('TSV round-trip: Product Manager NOT in cluster', tsvResult[0]?.appearances.length, 2);
// TSV with empty lines between rows
const tsvWithBlanks = [
'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\tlocation',
'',
'https://a.com/1\t2026-01-01\tgreenhouse\tBackend Engineer Platform\tAcme\tadded\tRemote',
'',
'https://a.com/2\t2026-02-01\tgreenhouse\tBackend Engineer Platform\tAcme\tadded\tRemote',
'',
].join('\n');
const blankParsed = parseScanHistory(tsvWithBlanks);
eq('TSV with blank lines: parsed correctly', blankParsed.length, 2);
eq('TSV with blank lines: 1 cluster', detectReposts(blankParsed, 90).length, 1);
// TSV with fewer than 5 columns (malformed)
const tsvShort = [
'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\tlocation',
'https://a.com/1\t2026-01-01\tgreenhouse',
'https://a.com/2\t2026-02-01\tgreenhouse\tBackend Engineer Platform\tAcme\tadded\tRemote',
].join('\n');
const shortParsed = parseScanHistory(tsvShort);
eq('TSV with short row: only valid row parsed', shortParsed.length, 1);
eq('TSV with short row: no clusters (need 2)', detectReposts(shortParsed, 90).length, 0);
// TSV with calendar-invalid date
const tsvBadDate = [
'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\tlocation',
'https://a.com/1\t2026-02-31\tgreenhouse\tBackend Engineer Platform\tAcme\tadded\tRemote',
'https://a.com/2\t2026-02-01\tgreenhouse\tBackend Engineer Platform\tAcme\tadded\tRemote',
].join('\n');
const badDateParsed = parseScanHistory(tsvBadDate);
eq('TSV with calendar-invalid date: only valid row parsed', badDateParsed.length, 1);
eq('TSV with calendar-invalid date: no clusters', detectReposts(badDateParsed, 90).length, 0);
// TSV with empty company field
const tsvEmptyCompany = [
'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\tlocation',
'https://a.com/1\t2026-01-01\tgreenhouse\tBackend Engineer Platform\t\tadded\tRemote',
'https://b.com/2\t2026-02-01\tgreenhouse\tBackend Engineer Platform\t\tadded\tRemote',
].join('\n');
const emptyCoParsed = parseScanHistory(tsvEmptyCompany);
// parseScanHistory produces rows with empty company, but detectReposts filters them out
eq('TSV with empty company: rows parsed (2)', emptyCoParsed.length, 2);
eq('TSV with empty company: no clusters (filtered by detectReposts)', detectReposts(emptyCoParsed, 90).length, 0);
// Headerless 5-column TSV parsing test (Older scan-history and seed file backward compat)
const headerlessTsv = [
'https://a.com/1\t2026-01-01\tgreenhouse\tBackend Engineer Platform\tAcme',
'https://a.com/2\t2026-02-01\tgreenhouse\tBackend Engineer Platform\tAcme',
].join('\n');
const headerlessParsed = parseScanHistory(headerlessTsv);
eq('Headerless 5-column TSV: both rows parsed successfully', headerlessParsed.length, 2);
eq('Headerless 5-column TSV: default status applied is added', headerlessParsed[0]?.status, 'added');
eq('Headerless 5-column TSV: 1 cluster found', detectReposts(headerlessParsed, 90).length, 1);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
// ============================================================================
// 10. Multi-company integration test
// ============================================================================
console.log('\n--- 10. multi-company integration ---');
const multiResult = detectReposts([
// Acme: 2 reposts of Backend Engineer (within window)
row({ url: 'https://acme.com/1', date: d('2026-01-01'), dateStr: '2026-01-01', title: 'Backend Engineer Platform', company: 'Acme' }),
row({ url: 'https://acme.com/2', date: d('2026-02-01'), dateStr: '2026-02-01', title: 'Backend Engineer Platform', company: 'Acme' }),
// Acme: distinct role (not a repost)
row({ url: 'https://acme.com/3', date: d('2026-01-15'), dateStr: '2026-01-15', title: 'Product Marketing Manager Senior', company: 'Acme' }),
// Beta: 2 reposts of SRE (within window)
row({ url: 'https://beta.com/1', date: d('2026-03-01'), dateStr: '2026-03-01', title: 'Senior Site Reliability Engineer', company: 'Beta' }),
row({ url: 'https://beta.com/2', date: d('2026-04-01'), dateStr: '2026-04-01', title: 'Senior Site Reliability Engineer', company: 'Beta' }),
// Gamma: only 1 posting (no repost)
row({ url: 'https://gamma.com/1', date: d('2026-02-01'), dateStr: '2026-02-01', title: 'Backend Engineer Platform', company: 'Gamma' }),
// Delta: same URL twice (dedup, not repost)
row({ url: 'https://delta.com/1', date: d('2026-01-01'), dateStr: '2026-01-01', title: 'Backend Engineer Platform', company: 'Delta' }),
row({ url: 'https://delta.com/1', date: d('2026-02-01'), dateStr: '2026-02-01', title: 'Backend Engineer Platform', company: 'Delta' }),
// Epsilon: expired rows (should be ignored)
row({ url: 'https://epsilon.com/1', date: d('2026-01-01'), dateStr: '2026-01-01', title: 'Backend Engineer Platform', company: 'Epsilon', status: 'skipped_expired' }),
row({ url: 'https://epsilon.com/2', date: d('2026-02-01'), dateStr: '2026-02-01', title: 'Backend Engineer Platform', company: 'Epsilon', status: 'skipped_expired' }),
], 90);
eq('multi-company: 2 clusters total (Acme + Beta)', multiResult.length, 2);
eq('multi-company: Beta first (more recent lastSeen)', multiResult[0].company, 'Beta');
eq('multi-company: Acme second', multiResult[1].company, 'Acme');
eq('multi-company: Acme cluster has 2 reposts', multiResult[1].repostCount, 2);
eq('multi-company: Beta cluster has 2 reposts', multiResult[0].repostCount, 2);
ok('multi-company: Gamma not in any cluster', !multiResult.some(c => c.company === 'Gamma'));
ok('multi-company: Delta not in any cluster (same URL = dedup)', !multiResult.some(c => c.company === 'Delta'));
ok('multi-company: Epsilon not in any cluster (expired)', !multiResult.some(c => c.company === 'Epsilon'));
// ============================================================================
// 10.5 Sliding window: overlapping repost pairs
// ============================================================================
console.log('\n--- 10.5 sliding window ---');
// CodeRabbit scenario: Jan 1, Mar 15, Jun 10, 90-day window
// Jan1-Mar15 = 73d (within), Mar15-Jun10 = 87d (within), Jan1-Jun10 = 160d (exceeds)
const sw1 = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-03-15'), dateStr: '2026-03-15' }),
row({ url: 'https://x.com/3', date: d('2026-06-10'), dateStr: '2026-06-10' }),
], 90);
eq('SW: 2 clusters (both overlapping pairs)', sw1.length, 2);
eq('SW: cluster 1 = Mar15-Jun10 (87d)', sw1[0]?.daysSpan, 87);
eq('SW: cluster 2 = Jan1-Mar15 (73d)', sw1[1]?.daysSpan, 73);
ok('SW: x.com/2 in both clusters (shared row)', sw1[0].appearances.some(a => a.url === 'https://x.com/2') && sw1[1].appearances.some(a => a.url === 'https://x.com/2'));
// All within window — 1 cluster, no over-splitting
eq('SW: 3 within window = 1 cluster', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01' }),
row({ url: 'https://x.com/3', date: d('2026-03-01'), dateStr: '2026-03-01' }),
], 90).length, 1);
// 4-row chain: A-B, B-C, C-D within window, A-C exceeds
const sw4 = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-03-01'), dateStr: '2026-03-01' }),
row({ url: 'https://x.com/3', date: d('2026-05-01'), dateStr: '2026-05-01' }),
row({ url: 'https://x.com/4', date: d('2026-07-01'), dateStr: '2026-07-01' }),
], 90);
eq('SW: 4-row chain = 3 overlapping clusters', sw4.length, 3);
if (sw4.length === 3) {
eq('SW: chain cluster 1 = C-D (61d)', sw4[0].daysSpan, 61);
eq('SW: chain cluster 2 = B-C (61d)', sw4[1].daysSpan, 61);
eq('SW: chain cluster 3 = A-B (59d)', sw4[2].daysSpan, 59);
}
// Exact 90-day boundary between consecutive
const sw6 = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-04-01'), dateStr: '2026-04-01' }),
row({ url: 'https://x.com/3', date: d('2026-06-30'), dateStr: '2026-06-30' }),
], 90);
eq('SW: exact 90d boundary = 2 clusters', sw6.length, 2);
eq('SW: boundary cluster 1 = 90d', sw6[0]?.daysSpan, 90);
eq('SW: boundary cluster 2 = 90d', sw6[1]?.daysSpan, 90);
// 91 days exceeds — only the valid pair detected
const sw7 = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-04-02'), dateStr: '2026-04-02' }),
row({ url: 'https://x.com/3', date: d('2026-07-01'), dateStr: '2026-07-01' }),
], 90);
eq('SW: 91d excluded, only B-C cluster', sw7.length, 1);
eq('SW: 91d cluster = B-C (90d)', sw7[0]?.daysSpan, 90);
// 6 rows, 45d apart — sliding produces 4 clusters of 3
const sw8 = detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/2', date: d('2026-02-15'), dateStr: '2026-02-15' }),
row({ url: 'https://x.com/3', date: d('2026-04-01'), dateStr: '2026-04-01' }),
row({ url: 'https://x.com/4', date: d('2026-05-16'), dateStr: '2026-05-16' }),
row({ url: 'https://x.com/5', date: d('2026-06-30'), dateStr: '2026-06-30' }),
row({ url: 'https://x.com/6', date: d('2026-08-14'), dateStr: '2026-08-14' }),
], 90);
eq('SW: 6 rows 45d apart = 4 clusters', sw8.length, 4);
if (sw8.length === 4) {
for (let i = 0; i < 4; i++) {
eq(`SW: 6-row cluster ${i+1} has 3 reposts`, sw8[i].repostCount, 3);
eq(`SW: 6-row cluster ${i+1} span = 90d`, sw8[i].daysSpan, 90);
}
}
// Regression: URL dedup with sliding window
eq('SW: URL dedup works with sliding', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01' }),
row({ url: 'https://x.com/1', date: d('2026-02-01'), dateStr: '2026-02-01' }),
row({ url: 'https://x.com/2', date: d('2026-03-01'), dateStr: '2026-03-01' }),
row({ url: 'https://x.com/3', date: d('2026-06-01'), dateStr: '2026-06-01' }),
], 90).length, 1);
// Regression: distinct roles not grouped
eq('SW: distinct roles not grouped', detectReposts([
row({ url: 'https://x.com/1', date: d('2026-01-01'), dateStr: '2026-01-01', title: 'Backend Engineer Platform' }),
row({ url: 'https://x.com/2', date: d('2026-02-01'), dateStr: '2026-02-01', title: 'Engineering Manager Platform Infrastructure' }),
row({ url: 'https://x.com/3', date: d('2026-03-01'), dateStr: '2026-03-01', title: 'Backend Engineer Platform' }),
], 90).length, 1);
// ============================================================================
// 11. Performance
// ============================================================================
console.log('\n--- 11. performance ---');
// Smoke tests only — no wall-clock thresholds (flaky on CI)
const perfRows = Array.from({ length: 100 }, (_, i) => row({
url: `https://x.com/${i}`,
date: new Date(2026, 0, 1 + (i % 30)),
dateStr: `2026-01-${String(1 + (i % 30)).padStart(2, '0')}`,
title: i % 2 === 0 ? 'Backend Engineer Platform' : 'Frontend Engineer React',
company: 'PerfCo',
}));
detectReposts(perfRows, 90);
ok('100 rows completes without throwing', true);
// 500 rows across 50 companies
const perfRows2 = Array.from({ length: 500 }, (_, i) => row({
url: `https://co${i % 50}.com/${i}`,
date: new Date(2026, 0, 1 + (i % 30)),
dateStr: `2026-01-${String(1 + (i % 30)).padStart(2, '0')}`,
title: i % 3 === 0 ? 'Backend Engineer Platform' : i % 3 === 1 ? 'Frontend Engineer React' : 'Product Manager Senior',
company: `Company${i % 50}`,
}));
detectReposts(perfRows2, 90);
ok('500 rows across 50 companies completes without throwing', true);
// ============================================================================
// 12. CLI behavior
// ============================================================================
console.log('\n--- 12. CLI behavior ---');
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), 'detect-reposts.mjs');
// Test --self-test exit code
try {
execFileSync('node', [scriptPath, '--self-test'], { encoding: 'utf-8', timeout: 10000 });
ok('--self-test exits 0', true);
} catch (e) {
ok('--self-test exits 0', false);
console.log(` exit code: ${e.status}, stderr: ${e.stderr?.slice(0, 200)}`);
}
// Test --window flag
const windowOut = execFileSync('node', [scriptPath, '--window', '30'], {
encoding: 'utf-8', timeout: 10000,
cwd: dirname(scriptPath),
});
const windowJson = JSON.parse(windowOut);
ok('--window produces valid JSON output', typeof windowJson === 'object' && 'metadata' in windowJson);
eq('--window sets windowDays in metadata', windowJson.metadata.windowDays, 30);
// Test --summary flag
const summaryOut = execFileSync('node', [scriptPath, '--summary'], {
encoding: 'utf-8', timeout: 10000,
cwd: dirname(scriptPath),
});
ok('--summary produces human-readable output', summaryOut.includes('Repost Detector'));
// Test no args (default JSON output)
const defaultOut = execFileSync('node', [scriptPath], {
encoding: 'utf-8', timeout: 10000,
cwd: dirname(scriptPath),
});
const defaultJson = JSON.parse(defaultOut);
ok('default produces valid JSON', typeof defaultJson === 'object');
ok('default has metadata', 'metadata' in defaultJson);
ok('default has clusters array', 'clusters' in defaultJson && Array.isArray(defaultJson.clusters));
eq('default windowDays = 90', defaultJson.metadata.windowDays, 90);
// Test --window with non-numeric value (falls back to default)
const badWindowOut = execFileSync('node', [scriptPath, '--window', 'abc'], {
encoding: 'utf-8', timeout: 10000,
cwd: dirname(scriptPath),
});
const badWindowJson = JSON.parse(badWindowOut);
eq('--window abc falls back to 90', badWindowJson.metadata.windowDays, 90);
// Test --window with no value (falls back to default)
const noWindowOut = execFileSync('node', [scriptPath, '--window'], {
encoding: 'utf-8', timeout: 10000,
cwd: dirname(scriptPath),
});
const noWindowJson = JSON.parse(noWindowOut);
eq('--window without value falls back to 90', noWindowJson.metadata.windowDays, 90);
// ============================================================================
// RESULTS
// ============================================================================
console.log(`\n${'='.repeat(78)}`);
console.log(` Results: ${passed} passed, ${failed} failed`);
if (failed > 0) {
console.log(`\n Failed tests:`);
for (const f of failures) console.log(` - ${f}`);
}
console.log(`${'='.repeat(78)}`);
process.exit(failed > 0 ? 1 : 0);