From 77994beaf2d6625782a8a2757fe2ff6f4d1529eb Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:05:51 +0500 Subject: [PATCH 1/9] Create saved-search.js --- lib/saved-search.js | 114 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 lib/saved-search.js diff --git a/lib/saved-search.js b/lib/saved-search.js new file mode 100644 index 0000000..44a841d --- /dev/null +++ b/lib/saved-search.js @@ -0,0 +1,114 @@ +import { countryKey, extractCountry } from './country.js'; + +export const SEARCH_MODES = ['text', 'vector', 'hybrid']; +export const ALERT_FREQUENCIES = ['off', 'daily', 'weekly']; +export const MAX_SAVED_SEARCHES_PER_USER = 25; +export const MAX_SEEN_LOGINS = 2000; + +export class SavedSearchValidationError extends Error {} + +function cleanString(value, { maxLength = 200 } = {}) { + if (typeof value !== 'string') return ''; + return value.trim().slice(0, maxLength); +} + +/** + * Normalize and validate a saved-search request body into a stored-shape + * criteria + alert object. Throws on structurally invalid input so bad + * requests fail loudly instead of silently saving garbage. + */ +export function normalizeSavedSearch(raw) { + if (!raw || typeof raw !== 'object') throw new SavedSearchValidationError('Request body is required'); + + const name = cleanString(raw.name, { maxLength: 80 }); + if (!name) throw new SavedSearchValidationError('name is required'); + + const query = cleanString(raw.criteria?.query, { maxLength: 200 }); + const mode = SEARCH_MODES.includes(raw.criteria?.mode) ? raw.criteria.mode : 'text'; + + const filters = raw.criteria?.filters || {}; + const country = cleanString(filters.country, { maxLength: 100 }) || null; + const language = cleanString(filters.language, { maxLength: 50 }) || null; + const minScore = Number.isFinite(filters.minScore) ? Math.min(Math.max(filters.minScore, 0), 100) : null; + + if (!query && !country && !language && minScore === null) { + throw new SavedSearchValidationError('At least one of query, country, language, or minScore is required'); + } + + const frequency = ALERT_FREQUENCIES.includes(raw.alert?.frequency) ? raw.alert.frequency : 'off'; + + return { + name, + criteria: { query, mode, filters: { country, language, minScore } }, + alert: { frequency, enabled: frequency !== 'off' }, + }; +} + +/** + * Given the developers currently matching a saved search and the set of + * logins already seen for that search, return only the genuinely new + * matches (incremental + deduplicated), and the updated seen set to persist. + */ +export function diffNewMatches(currentLogins, seenLogins = []) { + const seenSet = new Set(seenLogins); + const newLogins = currentLogins.filter(login => !seenSet.has(login)); + newLogins.forEach(login => seenSet.add(login)); + + // Cap the persisted seen set so it can't grow unbounded across years of runs. + let updatedSeenLogins = [...seenSet]; + if (updatedSeenLogins.length > MAX_SEEN_LOGINS) { + updatedSeenLogins = updatedSeenLogins.slice(updatedSeenLogins.length - MAX_SEEN_LOGINS); + } + + return { newLogins, updatedSeenLogins }; +} + +/** + * Defense-in-depth privacy guard: even if a caller passes an unfiltered + * developer list, never surface private or pending profiles. Mirrors the + * PUBLIC_FILTER predicate used by /api/search and /api/developers. + */ +export function isPubliclyVisible(developer) { + if (!developer) return false; + if (!developer.nomination) return true; + return developer.nomination.status === 'approved'; +} + +export function filterPubliclyVisible(developers) { + return developers.filter(isPubliclyVisible); +} + +/** Apply structured filters (country/language/minScore) to a developer list. */ +export function applyStructuredFilters(developers, filters = {}) { + const { country, language, minScore } = filters; + const wantedCountry = country ? countryKey(country) : null; + return developers.filter(developer => ( + (!wantedCountry || countryKey(extractCountry(developer.location)) === wantedCountry) + && (!language || developer.topLanguage?.toLowerCase() === language.toLowerCase()) + && (minScore === null || minScore === undefined || (developer.score ?? 0) >= minScore) + )); +} + +/** Apply the free-text portion of a saved search's criteria (text mode). */ +export function applyTextQuery(developers, query) { + if (!query) return developers; + const needle = query.toLowerCase(); + return developers.filter(developer => ( + developer.login?.toLowerCase().includes(needle) + || developer.name?.toLowerCase().includes(needle) + || developer.location?.toLowerCase().includes(needle) + || developer.bio?.toLowerCase().includes(needle) + || developer.topLanguage?.toLowerCase().includes(needle) + )); +} + +/** + * Full text-mode run pipeline: privacy -> structured filters -> text query. + * Vector/hybrid modes additionally rank by embedding similarity upstream + * (see lib/saved-search-run.js) before this narrows/filters the candidate set. + */ +export function runSavedSearchAgainstCandidates(candidates, criteria) { + const visible = filterPubliclyVisible(candidates); + const structured = applyStructuredFilters(visible, criteria.filters); + return applyTextQuery(structured, criteria.query); +} From d2ed94b4d4e4bf5c79f63d3ee73266c32e223f80 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:06:30 +0500 Subject: [PATCH 2/9] Create saved-search-store.js --- lib/saved-search-store.js | 106 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 lib/saved-search-store.js diff --git a/lib/saved-search-store.js b/lib/saved-search-store.js new file mode 100644 index 0000000..2bb39c2 --- /dev/null +++ b/lib/saved-search-store.js @@ -0,0 +1,106 @@ +import { randomUUID } from 'crypto'; +import { getCosmosContainer } from './cosmos.js'; +import { MAX_SAVED_SEARCHES_PER_USER } from './saved-search.js'; + +const memorySearches = new Map(); // login -> Map(searchId -> document) + +function getSavedSearchContainer() { + return getCosmosContainer(process.env.COSMOS_SAVED_SEARCH_CONTAINER || 'saved-searches'); +} + +function documentId(login, searchId) { + return `${login}:${searchId}`; +} + +function getMemoryBucket(login) { + if (!memorySearches.has(login)) memorySearches.set(login, new Map()); + return memorySearches.get(login); +} + +export async function listSavedSearches(login) { + const container = getSavedSearchContainer(); + if (!container) { + return [...getMemoryBucket(login).values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + const { resources } = await container.items.query({ + query: 'SELECT * FROM c WHERE c.login = @login ORDER BY c.createdAt DESC', + parameters: [{ name: '@login', value: login }], + }, { partitionKey: login }).fetchAll(); + return resources; +} + +export async function getSavedSearch(login, searchId) { + const container = getSavedSearchContainer(); + if (!container) return getMemoryBucket(login).get(searchId) || null; + + try { + const { resource } = await container.item(documentId(login, searchId), login).read(); + return resource || null; + } catch (error) { + if (error.code === 404) return null; + throw error; + } +} + +async function saveDocument(document) { + const container = getSavedSearchContainer(); + if (!container) { + getMemoryBucket(document.login).set(document.searchId, document); + return document; + } + const { resource } = await container.items.upsert(document); + return resource; +} + +export async function createSavedSearch(login, { name, criteria, alert }) { + const existing = await listSavedSearches(login); + if (existing.length >= MAX_SAVED_SEARCHES_PER_USER) { + const error = new Error(`You can save up to ${MAX_SAVED_SEARCHES_PER_USER} searches`); + error.status = 409; + throw error; + } + + const searchId = randomUUID(); + const now = new Date().toISOString(); + const document = { + id: documentId(login, searchId), + documentType: 'saved-search', + login, + searchId, + name, + criteria, + alert, + seenLogins: [], + lastRunAt: null, + createdAt: now, + updatedAt: now, + }; + return saveDocument(document); +} + +export async function updateSavedSearch(login, searchId, patch) { + const existing = await getSavedSearch(login, searchId); + if (!existing) return null; + const document = { ...existing, ...patch, updatedAt: new Date().toISOString() }; + return saveDocument(document); +} + +export async function deleteSavedSearch(login, searchId) { + const container = getSavedSearchContainer(); + if (!container) { + return getMemoryBucket(login).delete(searchId); + } + + try { + await container.item(documentId(login, searchId), login).delete(); + return true; + } catch (error) { + if (error.code === 404) return false; + throw error; + } +} + +export function __resetMemorySavedSearchStoreForTests() { + memorySearches.clear(); +} From ccbed5355b78bdab83cb9d403cef33396a134a59 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:07:09 +0500 Subject: [PATCH 3/9] Create saved-search-run.js --- lib/saved-search-run.js | 131 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 lib/saved-search-run.js diff --git a/lib/saved-search-run.js b/lib/saved-search-run.js new file mode 100644 index 0000000..f15a0f7 --- /dev/null +++ b/lib/saved-search-run.js @@ -0,0 +1,131 @@ +import { CosmosClient } from '@azure/cosmos'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { runSavedSearchAgainstCandidates } from './saved-search.js'; + +const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT; +const COSMOS_KEY = process.env.COSMOS_KEY; +const OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT; +const OPENAI_KEY = process.env.AZURE_OPENAI_KEY; +const EMBEDDING_DEPLOYMENT = process.env.EMBEDDING_DEPLOYMENT || 'text-embedding-3-small'; +const DATABASE = process.env.COSMOS_DATABASE || 'devglobe'; +const CONTAINER = process.env.COSMOS_CONTAINER || 'developers'; + +// Same predicate as /api/search and /api/developers: excludes pending/rejected +// self-nominations. Legacy documents with no `nomination` field stay public. +const PUBLIC_FILTER = "(NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved')"; +const CANDIDATE_FIELDS = 'c.id, c.login, c.name, c.avatarUrl, c.location, c.topLanguage, c.score, c.totalStars, c.followers, c.nomination'; +const CANDIDATE_POOL_SIZE = 200; + +async function getEmbedding(text) { + const url = `${OPENAI_ENDPOINT}/openai/deployments/${EMBEDDING_DEPLOYMENT}/embeddings?api-version=2024-02-01`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'api-key': OPENAI_KEY }, + body: JSON.stringify({ input: [text] }), + }); + const data = await res.json(); + return data.data[0].embedding; +} + +async function getSampleCandidates() { + const filePath = path.join(process.cwd(), 'data', 'developers-sample.json'); + const raw = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(raw); +} + +/** + * Fetch a candidate pool matching the saved search's mode + free-text query + * (before structured filters/privacy are applied — see runSavedSearch below). + * Falls back to bundled sample data when Cosmos isn't configured, same as + * every other route in this repo. + */ +async function fetchCandidates({ query, mode }) { + if (!COSMOS_ENDPOINT || !COSMOS_KEY) { + return getSampleCandidates(); + } + + const client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY }); + const container = client.database(DATABASE).container(CONTAINER); + + // No free-text query: structured-filters-only search. Pull a broad, scored + // candidate pool and let lib/saved-search.js narrow it down locally. + if (!query) { + const { resources } = await container.items.query({ + query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS} FROM c WHERE ${PUBLIC_FILTER} ORDER BY c.score DESC`, + }).fetchAll(); + return resources; + } + + if (mode === 'vector' || mode === 'hybrid') { + if (!OPENAI_ENDPOINT || !OPENAI_KEY) { + // Degrade to text mode rather than failing the whole saved search run. + mode = 'text'; + } + } + + if (mode === 'vector') { + const embedding = await getEmbedding(query); + const { resources } = await container.items.query({ + query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS} + FROM c WHERE ${PUBLIC_FILTER} ORDER BY VectorDistance(c.embedding, @embedding)`, + parameters: [{ name: '@embedding', value: embedding }], + }).fetchAll(); + return resources; + } + + if (mode === 'hybrid') { + const searchTerm = query.toLowerCase(); + const embedding = await getEmbedding(query); + const [vectorRes, textRes] = await Promise.all([ + container.items.query({ + query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS} + FROM c WHERE ${PUBLIC_FILTER} ORDER BY VectorDistance(c.embedding, @embedding)`, + parameters: [{ name: '@embedding', value: embedding }], + }).fetchAll(), + container.items.query({ + query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS} + FROM c + WHERE (CONTAINS(LOWER(c.login), @q) OR CONTAINS(LOWER(c.name), @q) + OR CONTAINS(LOWER(c.location), @q) OR CONTAINS(LOWER(c.bio), @q) + OR CONTAINS(LOWER(c.topLanguage), @q)) + AND ${PUBLIC_FILTER} + ORDER BY c.score DESC`, + parameters: [{ name: '@q', value: searchTerm }], + }).fetchAll(), + ]); + + // RRF fusion, same k as /api/search. + const k = 60; + const rrf = new Map(); + const allMap = new Map(); + vectorRes.resources.forEach((r, i) => { rrf.set(r.login, (rrf.get(r.login) || 0) + 1 / (k + i + 1)); allMap.set(r.login, r); }); + textRes.resources.forEach((r, i) => { rrf.set(r.login, (rrf.get(r.login) || 0) + 1 / (k + i + 1)); allMap.set(r.login, r); }); + return [...rrf.keys()].map(login => allMap.get(login)); + } + + // text mode + const searchTerm = query.toLowerCase(); + const { resources } = await container.items.query({ + query: `SELECT TOP ${CANDIDATE_POOL_SIZE} ${CANDIDATE_FIELDS} + FROM c + WHERE (CONTAINS(LOWER(c.login), @q) OR CONTAINS(LOWER(c.name), @q) + OR CONTAINS(LOWER(c.location), @q) OR CONTAINS(LOWER(c.bio), @q) + OR CONTAINS(LOWER(c.topLanguage), @q)) + AND ${PUBLIC_FILTER} + ORDER BY c.score DESC`, + parameters: [{ name: '@q', value: searchTerm }], + }).fetchAll(); + return resources; +} + +/** + * Execute a saved search's criteria end-to-end: fetch candidates for the + * requested mode, then apply privacy + structured filters + text query + * locally (lib/saved-search.js) so filtering logic stays in one, unit-tested + * place regardless of where the candidate pool came from. + */ +export async function runSavedSearch(criteria) { + const candidates = await fetchCandidates({ query: criteria.query, mode: criteria.mode }); + return runSavedSearchAgainstCandidates(candidates, criteria); +} From bc32fc53462ce508217012cd965d7dcbf7afa251 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:08:04 +0500 Subject: [PATCH 4/9] Implement GET and POST for saved searches API --- app/api/saved-searches/route.js | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 app/api/saved-searches/route.js diff --git a/app/api/saved-searches/route.js b/app/api/saved-searches/route.js new file mode 100644 index 0000000..add9eee --- /dev/null +++ b/app/api/saved-searches/route.js @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '../../../lib/auth.js'; +import { SavedSearchValidationError, normalizeSavedSearch } from '../../../lib/saved-search.js'; +import { createSavedSearch, listSavedSearches } from '../../../lib/saved-search-store.js'; + +export async function GET() { + const session = await getSession(); + if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + try { + const searches = await listSavedSearches(session.login); + return NextResponse.json({ searches }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (error) { + console.error('List saved searches failed:', error.message); + return NextResponse.json({ error: 'Unable to load saved searches' }, { status: 500 }); + } +} + +export async function POST(request) { + const session = await getSession(); + if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + let normalized; + try { + normalized = normalizeSavedSearch(await request.json()); + } catch (error) { + const message = error instanceof SavedSearchValidationError ? error.message : 'Invalid request body'; + return NextResponse.json({ error: message }, { status: 400 }); + } + + try { + const search = await createSavedSearch(session.login, normalized); + return NextResponse.json({ search }, { status: 201 }); + } catch (error) { + const status = error.status || 500; + console.error('Create saved search failed:', error.message); + return NextResponse.json({ error: error.message || 'Unable to save search' }, { status }); + } +} From 3c4380d25c7b6d2b2ccaf33759fd471f00df1e07 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:08:57 +0500 Subject: [PATCH 5/9] Implement PATCH and DELETE for saved searches --- app/api/saved-searches/[id]/route.js | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 app/api/saved-searches/[id]/route.js diff --git a/app/api/saved-searches/[id]/route.js b/app/api/saved-searches/[id]/route.js new file mode 100644 index 0000000..8cad36e --- /dev/null +++ b/app/api/saved-searches/[id]/route.js @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '../../../../lib/auth.js'; +import { ALERT_FREQUENCIES } from '../../../../lib/saved-search.js'; +import { deleteSavedSearch, getSavedSearch, updateSavedSearch } from '../../../../lib/saved-search-store.js'; + +export async function PATCH(request, { params }) { + const session = await getSession(); + if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { id } = await params; + const existing = await getSavedSearch(session.login, id); + if (!existing) return NextResponse.json({ error: 'Saved search not found' }, { status: 404 }); + + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + const patch = {}; + if (typeof body.name === 'string') { + const name = body.name.trim().slice(0, 80); + if (!name) return NextResponse.json({ error: 'name cannot be empty' }, { status: 400 }); + patch.name = name; + } + if (body.alert) { + if (!ALERT_FREQUENCIES.includes(body.alert.frequency)) { + return NextResponse.json({ error: 'Invalid alert frequency' }, { status: 400 }); + } + patch.alert = { frequency: body.alert.frequency, enabled: body.alert.frequency !== 'off' }; + } + + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }); + } + + try { + const search = await updateSavedSearch(session.login, id, patch); + return NextResponse.json({ search }); + } catch (error) { + console.error('Update saved search failed:', error.message); + return NextResponse.json({ error: 'Unable to update saved search' }, { status: 500 }); + } +} + +export async function DELETE(request, { params }) { + const session = await getSession(); + if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { id } = await params; + try { + const deleted = await deleteSavedSearch(session.login, id); + if (!deleted) return NextResponse.json({ error: 'Saved search not found' }, { status: 404 }); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('Delete saved search failed:', error.message); + return NextResponse.json({ error: 'Unable to delete saved search' }, { status: 500 }); + } +} From 86ce89cb6ac67c345fcf951ac62a6cc102c77141 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:09:35 +0500 Subject: [PATCH 6/9] Add POST endpoint for running saved searches Implement POST endpoint to run saved searches and update seen logins. --- app/api/saved-searches/[id]/run/route.js | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/api/saved-searches/[id]/run/route.js diff --git a/app/api/saved-searches/[id]/run/route.js b/app/api/saved-searches/[id]/run/route.js new file mode 100644 index 0000000..18b87e9 --- /dev/null +++ b/app/api/saved-searches/[id]/run/route.js @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '../../../../../lib/auth.js'; +import { diffNewMatches } from '../../../../../lib/saved-search.js'; +import { runSavedSearch } from '../../../../../lib/saved-search-run.js'; +import { getSavedSearch, updateSavedSearch } from '../../../../../lib/saved-search-store.js'; + +export async function POST(request, { params }) { + const session = await getSession(); + if (!session?.login) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { id } = await params; + const search = await getSavedSearch(session.login, id); + if (!search) return NextResponse.json({ error: 'Saved search not found' }, { status: 404 }); + + try { + const results = await runSavedSearch(search.criteria); + const currentLogins = results.map(developer => developer.login); + const { newLogins, updatedSeenLogins } = diffNewMatches(currentLogins, search.seenLogins); + + const updated = await updateSavedSearch(session.login, id, { + seenLogins: updatedSeenLogins, + lastRunAt: new Date().toISOString(), + }); + + return NextResponse.json({ + results, + newMatches: results.filter(developer => newLogins.includes(developer.login)), + lastRunAt: updated.lastRunAt, + }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (error) { + console.error('Run saved search failed:', error.message); + return NextResponse.json({ error: 'Unable to run saved search' }, { status: 500 }); + } +} From a6c5c7721708a34e67d61e54ea719ed03f4bfe24 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:10:25 +0500 Subject: [PATCH 7/9] Create saved-search.test.js --- docs/prd/saved-search.test.js | 166 ++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/prd/saved-search.test.js diff --git a/docs/prd/saved-search.test.js b/docs/prd/saved-search.test.js new file mode 100644 index 0000000..fe6be9c --- /dev/null +++ b/docs/prd/saved-search.test.js @@ -0,0 +1,166 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ALERT_FREQUENCIES, + SavedSearchValidationError, + applyStructuredFilters, + applyTextQuery, + diffNewMatches, + filterPubliclyVisible, + isPubliclyVisible, + normalizeSavedSearch, + runSavedSearchAgainstCandidates, +} from '../lib/saved-search.js'; + +function developer(overrides = {}) { + return { + login: 'torvalds', + name: 'Linus Torvalds', + location: 'Portland, OR, USA', + topLanguage: 'C', + score: 96, + ...overrides, + }; +} + +// --- Validation --------------------------------------------------------- + +test('normalizeSavedSearch requires a name', () => { + assert.throws(() => normalizeSavedSearch({ criteria: { query: 'rust' } }), SavedSearchValidationError); +}); + +test('normalizeSavedSearch requires at least one criterion', () => { + assert.throws(() => normalizeSavedSearch({ name: 'Empty' }), SavedSearchValidationError); +}); + +test('normalizeSavedSearch accepts a query-only search and defaults mode to text', () => { + const result = normalizeSavedSearch({ name: 'Rust devs', criteria: { query: 'rust' } }); + assert.equal(result.criteria.mode, 'text'); + assert.equal(result.criteria.query, 'rust'); + assert.equal(result.alert.frequency, 'off'); +}); + +test('normalizeSavedSearch accepts structured-filters-only search with no query', () => { + const result = normalizeSavedSearch({ name: 'Elite USA', criteria: { filters: { country: 'USA', minScore: 80 } } }); + assert.equal(result.criteria.filters.country, 'USA'); + assert.equal(result.criteria.filters.minScore, 80); +}); + +test('normalizeSavedSearch rejects an invalid mode by falling back to text', () => { + const result = normalizeSavedSearch({ name: 'X', criteria: { query: 'go', mode: 'nonsense' } }); + assert.equal(result.criteria.mode, 'text'); +}); + +test('normalizeSavedSearch clamps minScore into 0-100', () => { + const result = normalizeSavedSearch({ name: 'X', criteria: { filters: { minScore: 500 } } }); + assert.equal(result.criteria.filters.minScore, 100); +}); + +test('normalizeSavedSearch sets alert.enabled based on frequency', () => { + const daily = normalizeSavedSearch({ name: 'X', criteria: { query: 'go' }, alert: { frequency: 'daily' } }); + assert.equal(daily.alert.enabled, true); + const off = normalizeSavedSearch({ name: 'X', criteria: { query: 'go' }, alert: { frequency: 'off' } }); + assert.equal(off.alert.enabled, false); +}); + +test('ALERT_FREQUENCIES includes the documented options', () => { + assert.deepEqual(ALERT_FREQUENCIES, ['off', 'daily', 'weekly']); +}); + +// --- Structured + text filters ------------------------------------------ + +test('applyStructuredFilters matches country via extracted country, not raw substring', () => { + const usDev = developer({ location: 'Portland, OR, USA' }); + const ukDev = developer({ login: 'gaearon', location: 'London, UK' }); + const result = applyStructuredFilters([usDev, ukDev], { country: 'USA' }); + assert.deepEqual(result.map(d => d.login), ['torvalds']); +}); + +test('applyStructuredFilters matches language case-insensitively', () => { + const result = applyStructuredFilters([developer({ topLanguage: 'JavaScript' })], { language: 'javascript' }); + assert.equal(result.length, 1); +}); + +test('applyStructuredFilters enforces minScore', () => { + const result = applyStructuredFilters([developer({ score: 50 }), developer({ login: 'b', score: 90 })], { minScore: 80 }); + assert.deepEqual(result.map(d => d.login), ['b']); +}); + +test('applyTextQuery matches login, name, location, and language', () => { + const dev = developer(); + assert.equal(applyTextQuery([dev], 'torvalds').length, 1); + assert.equal(applyTextQuery([dev], 'linus').length, 1); + assert.equal(applyTextQuery([dev], 'portland').length, 1); + assert.equal(applyTextQuery([dev], 'nonexistent').length, 0); +}); + +// --- Privacy -------------------------------------------------------------- + +test('isPubliclyVisible excludes pending/rejected self-nominations', () => { + assert.equal(isPubliclyVisible(developer({ nomination: { status: 'pending' } })), false); + assert.equal(isPubliclyVisible(developer({ nomination: { status: 'rejected' } })), false); + assert.equal(isPubliclyVisible(developer({ nomination: { status: 'approved' } })), true); + assert.equal(isPubliclyVisible(developer()), true); // legacy docs with no nomination field +}); + +test('filterPubliclyVisible strips private/pending profiles from a result set', () => { + const visible = developer({ login: 'a' }); + const pending = developer({ login: 'b', nomination: { status: 'pending' } }); + assert.deepEqual(filterPubliclyVisible([visible, pending]).map(d => d.login), ['a']); +}); + +test('runSavedSearchAgainstCandidates never returns a private/pending profile even if it matches every filter', () => { + const pendingMatch = developer({ login: 'sneaky', nomination: { status: 'pending' } }); + const result = runSavedSearchAgainstCandidates([pendingMatch], { query: '', filters: {} }); + assert.equal(result.length, 0); +}); + +test('runSavedSearchAgainstCandidates combines privacy, structured filters, and text query', () => { + const candidates = [ + developer({ login: 'a', location: 'Portland, OR, USA', topLanguage: 'C', score: 96 }), + developer({ login: 'b', location: 'London, UK', topLanguage: 'C', score: 96 }), + developer({ login: 'c', location: 'Portland, OR, USA', topLanguage: 'Go', score: 96 }), + developer({ login: 'd', location: 'Portland, OR, USA', topLanguage: 'C', score: 10 }), + ]; + const result = runSavedSearchAgainstCandidates(candidates, { + query: '', + filters: { country: 'USA', language: 'C', minScore: 50 }, + }); + assert.deepEqual(result.map(d => d.login), ['a']); +}); + +// --- Incremental deduplicated new-match detection -------------------------- + +test('diffNewMatches treats every match as new on the first run', () => { + const { newLogins, updatedSeenLogins } = diffNewMatches(['a', 'b'], []); + assert.deepEqual(newLogins, ['a', 'b']); + assert.deepEqual(updatedSeenLogins, ['a', 'b']); +}); + +test('diffNewMatches only reports logins not already seen', () => { + const { newLogins, updatedSeenLogins } = diffNewMatches(['a', 'b', 'c'], ['a']); + assert.deepEqual(newLogins, ['b', 'c']); + assert.deepEqual(updatedSeenLogins, ['a', 'b', 'c']); +}); + +test('diffNewMatches reports nothing new when the result set is unchanged', () => { + const { newLogins } = diffNewMatches(['a', 'b'], ['a', 'b']); + assert.deepEqual(newLogins, []); +}); + +test('diffNewMatches is deduplicated: repeated runs never re-report the same login as new', () => { + const run1 = diffNewMatches(['a', 'b'], []); + assert.deepEqual(run1.newLogins, ['a', 'b']); + + const run2 = diffNewMatches(['a', 'b', 'c'], run1.updatedSeenLogins); + assert.deepEqual(run2.newLogins, ['c']); + + const run3 = diffNewMatches(['a', 'b', 'c'], run2.updatedSeenLogins); + assert.deepEqual(run3.newLogins, []); +}); + +test('diffNewMatches caps the persisted seen set so it cannot grow unbounded', () => { + const manyLogins = Array.from({ length: 2500 }, (_, i) => `dev${i}`); + const { updatedSeenLogins } = diffNewMatches(manyLogins, []); + assert.ok(updatedSeenLogins.length <= 2000); +}); From f678bb9490e213036dad5aa5f00baf0a56aac0b3 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:11:10 +0500 Subject: [PATCH 8/9] Update and rename saved-search.test.js to saved-searches.md --- docs/prd/saved-search.test.js | 166 ---------------------------------- docs/prd/saved-searches.md | 55 +++++++++++ 2 files changed, 55 insertions(+), 166 deletions(-) delete mode 100644 docs/prd/saved-search.test.js create mode 100644 docs/prd/saved-searches.md diff --git a/docs/prd/saved-search.test.js b/docs/prd/saved-search.test.js deleted file mode 100644 index fe6be9c..0000000 --- a/docs/prd/saved-search.test.js +++ /dev/null @@ -1,166 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - ALERT_FREQUENCIES, - SavedSearchValidationError, - applyStructuredFilters, - applyTextQuery, - diffNewMatches, - filterPubliclyVisible, - isPubliclyVisible, - normalizeSavedSearch, - runSavedSearchAgainstCandidates, -} from '../lib/saved-search.js'; - -function developer(overrides = {}) { - return { - login: 'torvalds', - name: 'Linus Torvalds', - location: 'Portland, OR, USA', - topLanguage: 'C', - score: 96, - ...overrides, - }; -} - -// --- Validation --------------------------------------------------------- - -test('normalizeSavedSearch requires a name', () => { - assert.throws(() => normalizeSavedSearch({ criteria: { query: 'rust' } }), SavedSearchValidationError); -}); - -test('normalizeSavedSearch requires at least one criterion', () => { - assert.throws(() => normalizeSavedSearch({ name: 'Empty' }), SavedSearchValidationError); -}); - -test('normalizeSavedSearch accepts a query-only search and defaults mode to text', () => { - const result = normalizeSavedSearch({ name: 'Rust devs', criteria: { query: 'rust' } }); - assert.equal(result.criteria.mode, 'text'); - assert.equal(result.criteria.query, 'rust'); - assert.equal(result.alert.frequency, 'off'); -}); - -test('normalizeSavedSearch accepts structured-filters-only search with no query', () => { - const result = normalizeSavedSearch({ name: 'Elite USA', criteria: { filters: { country: 'USA', minScore: 80 } } }); - assert.equal(result.criteria.filters.country, 'USA'); - assert.equal(result.criteria.filters.minScore, 80); -}); - -test('normalizeSavedSearch rejects an invalid mode by falling back to text', () => { - const result = normalizeSavedSearch({ name: 'X', criteria: { query: 'go', mode: 'nonsense' } }); - assert.equal(result.criteria.mode, 'text'); -}); - -test('normalizeSavedSearch clamps minScore into 0-100', () => { - const result = normalizeSavedSearch({ name: 'X', criteria: { filters: { minScore: 500 } } }); - assert.equal(result.criteria.filters.minScore, 100); -}); - -test('normalizeSavedSearch sets alert.enabled based on frequency', () => { - const daily = normalizeSavedSearch({ name: 'X', criteria: { query: 'go' }, alert: { frequency: 'daily' } }); - assert.equal(daily.alert.enabled, true); - const off = normalizeSavedSearch({ name: 'X', criteria: { query: 'go' }, alert: { frequency: 'off' } }); - assert.equal(off.alert.enabled, false); -}); - -test('ALERT_FREQUENCIES includes the documented options', () => { - assert.deepEqual(ALERT_FREQUENCIES, ['off', 'daily', 'weekly']); -}); - -// --- Structured + text filters ------------------------------------------ - -test('applyStructuredFilters matches country via extracted country, not raw substring', () => { - const usDev = developer({ location: 'Portland, OR, USA' }); - const ukDev = developer({ login: 'gaearon', location: 'London, UK' }); - const result = applyStructuredFilters([usDev, ukDev], { country: 'USA' }); - assert.deepEqual(result.map(d => d.login), ['torvalds']); -}); - -test('applyStructuredFilters matches language case-insensitively', () => { - const result = applyStructuredFilters([developer({ topLanguage: 'JavaScript' })], { language: 'javascript' }); - assert.equal(result.length, 1); -}); - -test('applyStructuredFilters enforces minScore', () => { - const result = applyStructuredFilters([developer({ score: 50 }), developer({ login: 'b', score: 90 })], { minScore: 80 }); - assert.deepEqual(result.map(d => d.login), ['b']); -}); - -test('applyTextQuery matches login, name, location, and language', () => { - const dev = developer(); - assert.equal(applyTextQuery([dev], 'torvalds').length, 1); - assert.equal(applyTextQuery([dev], 'linus').length, 1); - assert.equal(applyTextQuery([dev], 'portland').length, 1); - assert.equal(applyTextQuery([dev], 'nonexistent').length, 0); -}); - -// --- Privacy -------------------------------------------------------------- - -test('isPubliclyVisible excludes pending/rejected self-nominations', () => { - assert.equal(isPubliclyVisible(developer({ nomination: { status: 'pending' } })), false); - assert.equal(isPubliclyVisible(developer({ nomination: { status: 'rejected' } })), false); - assert.equal(isPubliclyVisible(developer({ nomination: { status: 'approved' } })), true); - assert.equal(isPubliclyVisible(developer()), true); // legacy docs with no nomination field -}); - -test('filterPubliclyVisible strips private/pending profiles from a result set', () => { - const visible = developer({ login: 'a' }); - const pending = developer({ login: 'b', nomination: { status: 'pending' } }); - assert.deepEqual(filterPubliclyVisible([visible, pending]).map(d => d.login), ['a']); -}); - -test('runSavedSearchAgainstCandidates never returns a private/pending profile even if it matches every filter', () => { - const pendingMatch = developer({ login: 'sneaky', nomination: { status: 'pending' } }); - const result = runSavedSearchAgainstCandidates([pendingMatch], { query: '', filters: {} }); - assert.equal(result.length, 0); -}); - -test('runSavedSearchAgainstCandidates combines privacy, structured filters, and text query', () => { - const candidates = [ - developer({ login: 'a', location: 'Portland, OR, USA', topLanguage: 'C', score: 96 }), - developer({ login: 'b', location: 'London, UK', topLanguage: 'C', score: 96 }), - developer({ login: 'c', location: 'Portland, OR, USA', topLanguage: 'Go', score: 96 }), - developer({ login: 'd', location: 'Portland, OR, USA', topLanguage: 'C', score: 10 }), - ]; - const result = runSavedSearchAgainstCandidates(candidates, { - query: '', - filters: { country: 'USA', language: 'C', minScore: 50 }, - }); - assert.deepEqual(result.map(d => d.login), ['a']); -}); - -// --- Incremental deduplicated new-match detection -------------------------- - -test('diffNewMatches treats every match as new on the first run', () => { - const { newLogins, updatedSeenLogins } = diffNewMatches(['a', 'b'], []); - assert.deepEqual(newLogins, ['a', 'b']); - assert.deepEqual(updatedSeenLogins, ['a', 'b']); -}); - -test('diffNewMatches only reports logins not already seen', () => { - const { newLogins, updatedSeenLogins } = diffNewMatches(['a', 'b', 'c'], ['a']); - assert.deepEqual(newLogins, ['b', 'c']); - assert.deepEqual(updatedSeenLogins, ['a', 'b', 'c']); -}); - -test('diffNewMatches reports nothing new when the result set is unchanged', () => { - const { newLogins } = diffNewMatches(['a', 'b'], ['a', 'b']); - assert.deepEqual(newLogins, []); -}); - -test('diffNewMatches is deduplicated: repeated runs never re-report the same login as new', () => { - const run1 = diffNewMatches(['a', 'b'], []); - assert.deepEqual(run1.newLogins, ['a', 'b']); - - const run2 = diffNewMatches(['a', 'b', 'c'], run1.updatedSeenLogins); - assert.deepEqual(run2.newLogins, ['c']); - - const run3 = diffNewMatches(['a', 'b', 'c'], run2.updatedSeenLogins); - assert.deepEqual(run3.newLogins, []); -}); - -test('diffNewMatches caps the persisted seen set so it cannot grow unbounded', () => { - const manyLogins = Array.from({ length: 2500 }, (_, i) => `dev${i}`); - const { updatedSeenLogins } = diffNewMatches(manyLogins, []); - assert.ok(updatedSeenLogins.length <= 2000); -}); diff --git a/docs/prd/saved-searches.md b/docs/prd/saved-searches.md new file mode 100644 index 0000000..f5f7a9e --- /dev/null +++ b/docs/prd/saved-searches.md @@ -0,0 +1,55 @@ +# PRD: Saved Developer Searches with New-Match Alerts + +**Issue:** [#166](https://github.com/sajeetharan/devglobe/issues/166) +**Related:** [#120](https://github.com/sajeetharan/devglobe/issues/120) (restore recent discovery session) +**Status:** MVP implementation + +## Summary + +Signed-in users can save a search (free-text query and/or structured filters: country, language, minimum score), re-run it on demand, rename or delete it, and opt in to a per-search alert frequency for when new public developers start matching. + +## Acceptance criteria covered + +- **Save, rename, run, and delete search criteria** — `POST /api/saved-searches`, `PATCH /api/saved-searches/[id]`, `POST /api/saved-searches/[id]/run`, `DELETE /api/saved-searches/[id]`. +- **Support current text/vector/hybrid mode and structured filters** — `lib/saved-search-run.js` fetches candidates using the same text/vector/hybrid patterns as `/api/search`, then `lib/saved-search.js` applies country/language/minScore filters and the free-text query locally. +- **New-match detection is incremental and deduplicated** — each saved search persists a `seenLogins` set; `diffNewMatches()` only reports logins not already in that set, then merges them in, so repeated runs never re-report the same match (see `tests/saved-search.test.js`). +- **Alerts are opt-in with per-search frequency controls** — `alert.frequency` is `off` by default; `daily` / `weekly` can be set per search via `PATCH`. +- **Private or pending profiles never appear** — `isPubliclyVisible()` mirrors the `PUBLIC_FILTER` predicate already used by `/api/search` and `/api/developers` (excludes any profile with `nomination.status` other than `approved`), applied as a local guard regardless of where the candidate pool came from. + +## Data model + +```json +{ + "id": "torvalds:3f1c...", + "documentType": "saved-search", + "login": "torvalds", + "searchId": "3f1c...", + "name": "Rust devs in Germany", + "criteria": { + "query": "rust", + "mode": "text", + "filters": { "country": "Germany", "language": null, "minScore": null } + }, + "alert": { "frequency": "weekly", "enabled": true }, + "seenLogins": ["torvalds", "gaearon"], + "lastRunAt": "2026-08-15T09:00:00.000Z", + "createdAt": "2026-08-15T08:00:00.000Z", + "updatedAt": "2026-08-15T09:00:00.000Z" +} +``` + +A user may have up to `MAX_SAVED_SEARCHES_PER_USER` (25) saved searches. `seenLogins` is capped at `MAX_SEEN_LOGINS` (2000, oldest dropped) so it can't grow unbounded over years of runs. + +## API contract + +- `GET /api/saved-searches` — auth required. Returns `{ searches }` for the signed-in user. +- `POST /api/saved-searches` — auth required. Body: `{ name, criteria: { query?, mode?, filters?: { country?, language?, minScore? } }, alert?: { frequency } }`. At least one of `query`/`country`/`language`/`minScore` is required. +- `PATCH /api/saved-searches/[id]` — auth required. Body: `{ name? }` and/or `{ alert: { frequency } }`. +- `DELETE /api/saved-searches/[id]` — auth required. +- `POST /api/saved-searches/[id]/run` — auth required. Executes the saved criteria now, updates `seenLogins`/`lastRunAt`, and returns `{ results, newMatches, lastRunAt }`. + +## Out of scope for this PR + +- **Alert delivery** (email/push notification when new matches appear on the `daily`/`weekly` cadence). This PR defines the opt-in frequency setting and the incremental new-match detection primitive (`diffNewMatches`) a scheduled job would call per saved search; the job itself, and the delivery channel, are a follow-up — the same split used for the personalized feed's event-generator jobs (#127 → #111). +- **Saved searches UI** (a "Save this search" button in `SearchBar.jsx`/`Leaderboard.jsx`, and a management screen). This PR ships the API contract first; the UI is a natural follow-up once the contract is reviewed. +- **Vector/hybrid mode when Azure OpenAI isn't configured** — `lib/saved-search-run.js` degrades to text mode in that case rather than failing the run, matching how `/api/search` already requires OpenAI config for those modes. From b5bb05a5df98195c2e99d0203fc9c98128322a6f Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sun, 16 Aug 2026 12:11:50 +0500 Subject: [PATCH 9/9] Add tests for saved search functionality --- tests/saved-search.test.js | 166 +++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/saved-search.test.js diff --git a/tests/saved-search.test.js b/tests/saved-search.test.js new file mode 100644 index 0000000..fe6be9c --- /dev/null +++ b/tests/saved-search.test.js @@ -0,0 +1,166 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ALERT_FREQUENCIES, + SavedSearchValidationError, + applyStructuredFilters, + applyTextQuery, + diffNewMatches, + filterPubliclyVisible, + isPubliclyVisible, + normalizeSavedSearch, + runSavedSearchAgainstCandidates, +} from '../lib/saved-search.js'; + +function developer(overrides = {}) { + return { + login: 'torvalds', + name: 'Linus Torvalds', + location: 'Portland, OR, USA', + topLanguage: 'C', + score: 96, + ...overrides, + }; +} + +// --- Validation --------------------------------------------------------- + +test('normalizeSavedSearch requires a name', () => { + assert.throws(() => normalizeSavedSearch({ criteria: { query: 'rust' } }), SavedSearchValidationError); +}); + +test('normalizeSavedSearch requires at least one criterion', () => { + assert.throws(() => normalizeSavedSearch({ name: 'Empty' }), SavedSearchValidationError); +}); + +test('normalizeSavedSearch accepts a query-only search and defaults mode to text', () => { + const result = normalizeSavedSearch({ name: 'Rust devs', criteria: { query: 'rust' } }); + assert.equal(result.criteria.mode, 'text'); + assert.equal(result.criteria.query, 'rust'); + assert.equal(result.alert.frequency, 'off'); +}); + +test('normalizeSavedSearch accepts structured-filters-only search with no query', () => { + const result = normalizeSavedSearch({ name: 'Elite USA', criteria: { filters: { country: 'USA', minScore: 80 } } }); + assert.equal(result.criteria.filters.country, 'USA'); + assert.equal(result.criteria.filters.minScore, 80); +}); + +test('normalizeSavedSearch rejects an invalid mode by falling back to text', () => { + const result = normalizeSavedSearch({ name: 'X', criteria: { query: 'go', mode: 'nonsense' } }); + assert.equal(result.criteria.mode, 'text'); +}); + +test('normalizeSavedSearch clamps minScore into 0-100', () => { + const result = normalizeSavedSearch({ name: 'X', criteria: { filters: { minScore: 500 } } }); + assert.equal(result.criteria.filters.minScore, 100); +}); + +test('normalizeSavedSearch sets alert.enabled based on frequency', () => { + const daily = normalizeSavedSearch({ name: 'X', criteria: { query: 'go' }, alert: { frequency: 'daily' } }); + assert.equal(daily.alert.enabled, true); + const off = normalizeSavedSearch({ name: 'X', criteria: { query: 'go' }, alert: { frequency: 'off' } }); + assert.equal(off.alert.enabled, false); +}); + +test('ALERT_FREQUENCIES includes the documented options', () => { + assert.deepEqual(ALERT_FREQUENCIES, ['off', 'daily', 'weekly']); +}); + +// --- Structured + text filters ------------------------------------------ + +test('applyStructuredFilters matches country via extracted country, not raw substring', () => { + const usDev = developer({ location: 'Portland, OR, USA' }); + const ukDev = developer({ login: 'gaearon', location: 'London, UK' }); + const result = applyStructuredFilters([usDev, ukDev], { country: 'USA' }); + assert.deepEqual(result.map(d => d.login), ['torvalds']); +}); + +test('applyStructuredFilters matches language case-insensitively', () => { + const result = applyStructuredFilters([developer({ topLanguage: 'JavaScript' })], { language: 'javascript' }); + assert.equal(result.length, 1); +}); + +test('applyStructuredFilters enforces minScore', () => { + const result = applyStructuredFilters([developer({ score: 50 }), developer({ login: 'b', score: 90 })], { minScore: 80 }); + assert.deepEqual(result.map(d => d.login), ['b']); +}); + +test('applyTextQuery matches login, name, location, and language', () => { + const dev = developer(); + assert.equal(applyTextQuery([dev], 'torvalds').length, 1); + assert.equal(applyTextQuery([dev], 'linus').length, 1); + assert.equal(applyTextQuery([dev], 'portland').length, 1); + assert.equal(applyTextQuery([dev], 'nonexistent').length, 0); +}); + +// --- Privacy -------------------------------------------------------------- + +test('isPubliclyVisible excludes pending/rejected self-nominations', () => { + assert.equal(isPubliclyVisible(developer({ nomination: { status: 'pending' } })), false); + assert.equal(isPubliclyVisible(developer({ nomination: { status: 'rejected' } })), false); + assert.equal(isPubliclyVisible(developer({ nomination: { status: 'approved' } })), true); + assert.equal(isPubliclyVisible(developer()), true); // legacy docs with no nomination field +}); + +test('filterPubliclyVisible strips private/pending profiles from a result set', () => { + const visible = developer({ login: 'a' }); + const pending = developer({ login: 'b', nomination: { status: 'pending' } }); + assert.deepEqual(filterPubliclyVisible([visible, pending]).map(d => d.login), ['a']); +}); + +test('runSavedSearchAgainstCandidates never returns a private/pending profile even if it matches every filter', () => { + const pendingMatch = developer({ login: 'sneaky', nomination: { status: 'pending' } }); + const result = runSavedSearchAgainstCandidates([pendingMatch], { query: '', filters: {} }); + assert.equal(result.length, 0); +}); + +test('runSavedSearchAgainstCandidates combines privacy, structured filters, and text query', () => { + const candidates = [ + developer({ login: 'a', location: 'Portland, OR, USA', topLanguage: 'C', score: 96 }), + developer({ login: 'b', location: 'London, UK', topLanguage: 'C', score: 96 }), + developer({ login: 'c', location: 'Portland, OR, USA', topLanguage: 'Go', score: 96 }), + developer({ login: 'd', location: 'Portland, OR, USA', topLanguage: 'C', score: 10 }), + ]; + const result = runSavedSearchAgainstCandidates(candidates, { + query: '', + filters: { country: 'USA', language: 'C', minScore: 50 }, + }); + assert.deepEqual(result.map(d => d.login), ['a']); +}); + +// --- Incremental deduplicated new-match detection -------------------------- + +test('diffNewMatches treats every match as new on the first run', () => { + const { newLogins, updatedSeenLogins } = diffNewMatches(['a', 'b'], []); + assert.deepEqual(newLogins, ['a', 'b']); + assert.deepEqual(updatedSeenLogins, ['a', 'b']); +}); + +test('diffNewMatches only reports logins not already seen', () => { + const { newLogins, updatedSeenLogins } = diffNewMatches(['a', 'b', 'c'], ['a']); + assert.deepEqual(newLogins, ['b', 'c']); + assert.deepEqual(updatedSeenLogins, ['a', 'b', 'c']); +}); + +test('diffNewMatches reports nothing new when the result set is unchanged', () => { + const { newLogins } = diffNewMatches(['a', 'b'], ['a', 'b']); + assert.deepEqual(newLogins, []); +}); + +test('diffNewMatches is deduplicated: repeated runs never re-report the same login as new', () => { + const run1 = diffNewMatches(['a', 'b'], []); + assert.deepEqual(run1.newLogins, ['a', 'b']); + + const run2 = diffNewMatches(['a', 'b', 'c'], run1.updatedSeenLogins); + assert.deepEqual(run2.newLogins, ['c']); + + const run3 = diffNewMatches(['a', 'b', 'c'], run2.updatedSeenLogins); + assert.deepEqual(run3.newLogins, []); +}); + +test('diffNewMatches caps the persisted seen set so it cannot grow unbounded', () => { + const manyLogins = Array.from({ length: 2500 }, (_, i) => `dev${i}`); + const { updatedSeenLogins } = diffNewMatches(manyLogins, []); + assert.ok(updatedSeenLogins.length <= 2000); +});