332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import ts from "typescript";
|
|
|
|
const outreachPath = join(process.cwd(), "convex", "outreach.ts");
|
|
const schemaPath = join(process.cwd(), "convex", "schema.ts");
|
|
const outreachSource = existsSync(outreachPath)
|
|
? readFileSync(outreachPath, "utf8")
|
|
: "";
|
|
const schemaSource = existsSync(schemaPath)
|
|
? readFileSync(schemaPath, "utf8")
|
|
: "";
|
|
|
|
const sourceFile = ts.createSourceFile(
|
|
"outreach.ts",
|
|
outreachSource,
|
|
ts.ScriptTarget.ES2022,
|
|
true,
|
|
ts.ScriptKind.TS,
|
|
);
|
|
|
|
function getExportedConstNames(file: ts.SourceFile) {
|
|
const names = new Set<string>();
|
|
|
|
const visit = (node: ts.Node) => {
|
|
if (ts.isVariableStatement(node)) {
|
|
const isExported = node.modifiers?.some(
|
|
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
|
|
);
|
|
const isConst = Boolean(node.declarationList.flags & ts.NodeFlags.Const);
|
|
|
|
if (isExported && isConst) {
|
|
for (const declaration of node.declarationList.declarations) {
|
|
if (ts.isIdentifier(declaration.name)) {
|
|
names.add(declaration.name.text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
|
|
ts.forEachChild(file, visit);
|
|
return names;
|
|
}
|
|
|
|
function extractExportSource(name: string) {
|
|
const marker = `export const ${name} = `;
|
|
const declarationIndex = outreachSource.indexOf(marker);
|
|
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}.`);
|
|
|
|
const openBraceIndex = outreachSource.indexOf("{", declarationIndex);
|
|
let depth = 0;
|
|
let end = -1;
|
|
|
|
for (let index = openBraceIndex; index < outreachSource.length; index += 1) {
|
|
const char = outreachSource[index];
|
|
if (char === "{") {
|
|
depth += 1;
|
|
} else if (char === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
end = index;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.notEqual(end, -1, `Expected balanced braces for ${name}.`);
|
|
return outreachSource.slice(openBraceIndex, end + 1);
|
|
}
|
|
|
|
function hasPattern(source: string, pattern: RegExp, message: string) {
|
|
assert.equal(pattern.test(source), true, message);
|
|
}
|
|
|
|
function lacksPattern(source: string, pattern: RegExp, message: string) {
|
|
assert.equal(pattern.test(source), false, message);
|
|
}
|
|
|
|
test("outreach review module exports authenticated review contracts", () => {
|
|
assert.equal(existsSync(outreachPath), true, "outreach.ts should be present.");
|
|
|
|
const exports = getExportedConstNames(sourceFile);
|
|
for (const exportName of [
|
|
"listReviewWorkspace",
|
|
"saveReviewDraft",
|
|
"approveEmailDraft",
|
|
]) {
|
|
assert.equal(exports.has(exportName), true, `Expected export: ${exportName}`);
|
|
}
|
|
|
|
hasPattern(
|
|
outreachSource,
|
|
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:QueryCtx\s*\|\s*MutationCtx|MutationCtx\s*\|\s*QueryCtx)\s*\)/,
|
|
"Module should define a local requireOperator helper usable from queries and mutations.",
|
|
);
|
|
hasPattern(
|
|
outreachSource,
|
|
/ctx\.auth\.getUserIdentity\(\)/,
|
|
"requireOperator should derive the operator identity from Convex auth.",
|
|
);
|
|
hasPattern(
|
|
outreachSource,
|
|
/Nicht autorisiert/,
|
|
"Unauthenticated review calls should fail clearly.",
|
|
);
|
|
});
|
|
|
|
test("listReviewWorkspace is bounded, authenticated, and joins review context", () => {
|
|
const listSource = extractExportSource("listReviewWorkspace");
|
|
const reviewSource = `${listSource}\n${outreachSource}`;
|
|
|
|
hasPattern(outreachSource, /export const listReviewWorkspace = query\(/, "Review workspace should be a public query.");
|
|
hasPattern(listSource, /requireOperator\(ctx\)/, "Review workspace should require auth.");
|
|
hasPattern(listSource, /limit:\s*v\.optional\(v\.number\(\)\)/, "Review workspace should accept optional limit.");
|
|
hasPattern(listSource, /normalizeListLimit\(args\.limit\)/, "Review workspace should normalize the requested limit.");
|
|
lacksPattern(listSource, /\.collect\(/, "Review workspace must not use unbounded collect().");
|
|
|
|
hasPattern(
|
|
listSource,
|
|
/query\("leads"\)[\s\S]*?withIndex\("by_contactStatus_and_updatedAt"[\s\S]*?eq\("contactStatus",\s*"outreach_ready"\)[\s\S]*?\.order\("desc"\)[\s\S]*?\.take\(candidateLimit\)/,
|
|
"Review workspace should include newest outreach-ready leads via contactStatus+updatedAt.",
|
|
);
|
|
for (const [approvalStatus, sendStatus] of [
|
|
["draft", "not_sent"],
|
|
["draft", "queued"],
|
|
["draft", "failed"],
|
|
["approved", "not_sent"],
|
|
["approved", "queued"],
|
|
["approved", "failed"],
|
|
]) {
|
|
hasPattern(
|
|
listSource,
|
|
new RegExp(
|
|
`query\\("outreachRecords"\\)[\\s\\S]*?withIndex\\("by_approvalStatus_and_sendStatus_and_updatedAt"[\\s\\S]*?eq\\("approvalStatus",\\s*"${approvalStatus}"\\)[\\s\\S]*?eq\\("sendStatus",\\s*"${sendStatus}"\\)[\\s\\S]*?\\.order\\("desc"\\)[\\s\\S]*?\\.take\\(candidateLimit\\)`,
|
|
),
|
|
`Review workspace should fetch newest ${approvalStatus}/${sendStatus} outreach via combined eligibility+updatedAt index.`,
|
|
);
|
|
}
|
|
lacksPattern(
|
|
listSource,
|
|
/withIndex\("by_approvalStatus_and_updatedAt"/,
|
|
"Review workspace should not depend on approval-only bounded windows for outreach eligibility.",
|
|
);
|
|
lacksPattern(
|
|
listSource,
|
|
/withIndex\("by_sendStatus_and_updatedAt"/,
|
|
"Review workspace should not depend on send-only bounded windows for outreach eligibility.",
|
|
);
|
|
hasPattern(
|
|
listSource,
|
|
/\.\.\.draftNotSentOutreach[\s\S]*\.\.\.draftQueuedOutreach[\s\S]*\.\.\.draftFailedOutreach[\s\S]*\.\.\.approvedNotSentOutreach[\s\S]*\.\.\.approvedQueuedOutreach[\s\S]*\.\.\.approvedFailedOutreach/,
|
|
"Review workspace should combine only eligible approval/send-status candidate windows.",
|
|
);
|
|
hasPattern(
|
|
listSource,
|
|
/approvalStatus\s*===\s*"draft"[\s\S]*?approvalStatus\s*===\s*"approved"/,
|
|
"Review workspace should include draft and approved unsent outreach records.",
|
|
);
|
|
hasPattern(
|
|
listSource,
|
|
/sendStatus\s*!==\s*"sent"/,
|
|
"Review workspace should exclude sent outreach records.",
|
|
);
|
|
hasPattern(
|
|
listSource,
|
|
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.sortAt\s*-\s*a\.sortAt\s*\)/,
|
|
"Review rows should be newest first.",
|
|
);
|
|
hasPattern(listSource, /slice\(0,\s*limit\)/, "Review rows should be capped to the normalized limit.");
|
|
|
|
for (const tableName of [
|
|
"audits",
|
|
"auditGenerations",
|
|
"pageSpeedResults",
|
|
"websiteCrawlPages",
|
|
"websiteEmailCandidates",
|
|
]) {
|
|
hasPattern(
|
|
reviewSource,
|
|
new RegExp(`query\\("${tableName}"\\)[\\s\\S]*?\\.take\\(\\s*\\d+\\s*\\)`),
|
|
`${tableName} join should be bounded with take(n).`,
|
|
);
|
|
}
|
|
|
|
for (const fieldName of [
|
|
"lead",
|
|
"latestOutreach",
|
|
"audit",
|
|
"auditGenerations",
|
|
"usedSkills",
|
|
"skillSummaries",
|
|
"sourceSummaries",
|
|
"pageSpeedResults",
|
|
"crawlPages",
|
|
"emailCandidates",
|
|
]) {
|
|
hasPattern(
|
|
reviewSource,
|
|
new RegExp(`${fieldName}:`),
|
|
`Review rows should include ${fieldName}.`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("schema defines recency indexes for outreach review bounded reads", () => {
|
|
assert.equal(existsSync(schemaPath), true, "schema.ts should be present.");
|
|
|
|
for (const [indexName, fieldsPattern] of [
|
|
["by_contactStatus_and_updatedAt", String.raw`\[\s*"contactStatus",\s*"updatedAt",?\s*\]`],
|
|
["by_approvalStatus_and_updatedAt", String.raw`\[\s*"approvalStatus",\s*"updatedAt",?\s*\]`],
|
|
["by_sendStatus_and_updatedAt", String.raw`\[\s*"sendStatus",\s*"updatedAt",?\s*\]`],
|
|
[
|
|
"by_approvalStatus_and_sendStatus_and_updatedAt",
|
|
String.raw`\[\s*"approvalStatus",\s*"sendStatus",\s*"updatedAt",?\s*\]`,
|
|
],
|
|
]) {
|
|
hasPattern(
|
|
schemaSource,
|
|
new RegExp(`\\.index\\("${indexName}",\\s*${fieldsPattern}`),
|
|
`Schema should define ${indexName} for newest-first bounded review reads.`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("upsertFromAuditGeneration preserves review boundaries for generated copy", () => {
|
|
const upsertSource = extractExportSource("upsertFromAuditGeneration");
|
|
|
|
hasPattern(
|
|
outreachSource,
|
|
/export const upsertFromAuditGeneration = internalMutation\(/,
|
|
"upsertFromAuditGeneration should remain an internal mutation.",
|
|
);
|
|
hasPattern(upsertSource, /ctx\.db\.get\(args\.leadId\)/, "upsert should verify the lead exists.");
|
|
hasPattern(upsertSource, /!lead/, "upsert should reject missing leads.");
|
|
hasPattern(upsertSource, /ctx\.db\.get\(args\.auditId\)/, "upsert should load provided audits.");
|
|
hasPattern(upsertSource, /!audit/, "upsert should reject missing audits.");
|
|
hasPattern(
|
|
upsertSource,
|
|
/audit\.leadId\s*!==\s*args\.leadId/,
|
|
"upsert should reject auditId values that belong to a different lead.",
|
|
);
|
|
hasPattern(
|
|
upsertSource,
|
|
/current\.sendStatus\s*===\s*"sent"[\s\S]*?ctx\.db\.insert\(\s*"outreachRecords"/,
|
|
"upsert should create a new draft record instead of patching a sent outreach record.",
|
|
);
|
|
hasPattern(
|
|
upsertSource,
|
|
/ctx\.db\.patch\(current\._id,[\s\S]*approvalStatus:\s*"draft"/,
|
|
"Generated copy changes should reset existing unsent outreach records to draft.",
|
|
);
|
|
hasPattern(
|
|
upsertSource,
|
|
/approvalStatus:\s*"draft"[\s\S]*sendStatus:\s*"not_sent"/,
|
|
"New generated outreach records should start as unsent drafts.",
|
|
);
|
|
});
|
|
|
|
test("sensitive public outreach exports require operators and validate references", () => {
|
|
const createSource = extractExportSource("create");
|
|
const listSource = extractExportSource("list");
|
|
|
|
hasPattern(createSource, /requireOperator\(ctx\)/, "create should require operator auth.");
|
|
hasPattern(listSource, /requireOperator\(ctx\)/, "list should require operator auth.");
|
|
hasPattern(createSource, /ctx\.db\.get\(args\.leadId\)/, "create should verify the lead exists.");
|
|
hasPattern(createSource, /!lead/, "create should reject missing leads.");
|
|
hasPattern(createSource, /ctx\.db\.get\(args\.auditId\)/, "create should load provided audits.");
|
|
hasPattern(createSource, /!audit/, "create should reject missing audits.");
|
|
hasPattern(
|
|
createSource,
|
|
/audit\.leadId\s*!==\s*args\.leadId/,
|
|
"create should reject auditId values that belong to a different lead.",
|
|
);
|
|
});
|
|
|
|
test("saveReviewDraft validates editable fields and never edits sent records", () => {
|
|
const saveSource = extractExportSource("saveReviewDraft");
|
|
|
|
hasPattern(outreachSource, /export const saveReviewDraft = mutation\(/, "saveReviewDraft should be a mutation.");
|
|
hasPattern(saveSource, /requireOperator\(ctx\)/, "saveReviewDraft should require auth.");
|
|
hasPattern(saveSource, /id:\s*v\.id\("outreachRecords"\)/, "saveReviewDraft should validate the outreach id.");
|
|
hasPattern(saveSource, /strategy:\s*strategy/, "saveReviewDraft should validate strategy with the shared strategy validator.");
|
|
|
|
for (const fieldName of [
|
|
"phoneScript",
|
|
"emailSubject",
|
|
"emailBody",
|
|
"followUpDraft",
|
|
]) {
|
|
hasPattern(
|
|
saveSource,
|
|
new RegExp(`${fieldName}:\\s*v\\.optional\\(v\\.string\\(\\)\\)`),
|
|
`${fieldName} should be optional editable copy.`,
|
|
);
|
|
}
|
|
|
|
hasPattern(saveSource, /ctx\.db\.get\(args\.id\)/, "saveReviewDraft should load the outreach record.");
|
|
hasPattern(saveSource, /!outreach/, "saveReviewDraft should reject missing outreach.");
|
|
hasPattern(saveSource, /outreach\.sendStatus\s*===\s*"sent"/, "saveReviewDraft should reject sent outreach records.");
|
|
hasPattern(saveSource, /approvalStatus:\s*"draft"/, "Saving edits should reset approval to draft.");
|
|
hasPattern(saveSource, /updatedAt:\s*now/, "Saving edits should stamp updatedAt.");
|
|
hasPattern(saveSource, /ctx\.db\.patch\(args\.id/, "saveReviewDraft should patch the existing outreach record.");
|
|
});
|
|
|
|
test("approveEmailDraft validates approval prerequisites and preserves send separation", () => {
|
|
const approveSource = extractExportSource("approveEmailDraft");
|
|
|
|
hasPattern(outreachSource, /export const approveEmailDraft = mutation\(/, "approveEmailDraft should be a mutation.");
|
|
hasPattern(approveSource, /requireOperator\(ctx\)/, "approveEmailDraft should require auth.");
|
|
hasPattern(approveSource, /id:\s*v\.id\("outreachRecords"\)/, "approveEmailDraft should validate the outreach id.");
|
|
hasPattern(approveSource, /ctx\.db\.get\(args\.id\)/, "approveEmailDraft should load the outreach record.");
|
|
hasPattern(approveSource, /!outreach/, "approveEmailDraft should reject missing outreach.");
|
|
hasPattern(approveSource, /outreach\.sendStatus\s*===\s*"sent"/, "approveEmailDraft should reject sent outreach records.");
|
|
hasPattern(approveSource, /ctx\.db\.get\(outreach\.leadId\)/, "approveEmailDraft should load the linked lead.");
|
|
hasPattern(approveSource, /lead\.email\?\.trim\(\)/, "approveEmailDraft should require a trimmed recipient email.");
|
|
hasPattern(approveSource, /outreach\.emailSubject\?\.trim\(\)/, "approveEmailDraft should require a trimmed subject.");
|
|
hasPattern(approveSource, /outreach\.emailBody\?\.trim\(\)/, "approveEmailDraft should require a trimmed body.");
|
|
hasPattern(approveSource, /approvalStatus:\s*"approved"/, "Approval should mark only the approval status.");
|
|
hasPattern(approveSource, /updatedAt:\s*now/, "Approval should stamp updatedAt.");
|
|
hasPattern(approveSource, /recipient:/, "Approval should return recipient context.");
|
|
hasPattern(approveSource, /subject:/, "Approval should return subject context.");
|
|
hasPattern(approveSource, /auditSlug:/, "Approval should return audit slug context when available.");
|
|
|
|
lacksPattern(approveSource, /sendStatus\s*:/, "Approval must not alter sendStatus.");
|
|
lacksPattern(approveSource, /ctx\.scheduler|runAfter|runMutation|nodemailer|smtp|smpp|sendEmail/i, "Approval must not queue or send email/SMPP/Nodemailer work.");
|
|
});
|