Files
govoplan-campaign/webui/tests/import-utils.test.ts
T

287 lines
16 KiB
TypeScript

import {
buildImportTableFromRows,
applyRecipientMappingProfile,
buildImportTable,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile,
defaultColumnMappings,
matchRecipientMappingProfiles,
materializeRecipientImport,
parseDelimitedText,
xlsxSheetsFromArrayBuffer,
type RecipientColumnMapping
} from "../src/features/campaigns/utils/bulkImport";
import {
distributionListDrift,
materializeDistributionListExpansion,
type DistributionListExpansionSnapshot
} from "../src/features/campaigns/utils/distributionListImport";
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function mappingFor(mappings: RecipientColumnMapping[], columnIndex: number): RecipientColumnMapping {
return mappings.find((mapping) => mapping.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" };
}
function base64ToArrayBuffer(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
const parsed = parseDelimitedText('email;name;field.note\n"alice@example.org";"Alice; A";"hello"', ";");
assert(parsed[1][1] === "Alice; A", "quoted delimiters are preserved");
const csv = [
"email;name;customer_id;field.contract_no;attachment_pattern;active",
"alice@example.org;Alice;C-1;CN-1;${customer_id}.pdf|archive/*.pdf;yes",
"bad-address;Broken;C-2;CN-2;broken.pdf;yes",
";Missing;C-3;CN-3;;yes"
].join("\n");
const existingFields = [{ name: "customer_id", label: "Customer ID", type: "string", required: false, can_override: true }];
const table = buildImportTable(csv, { delimiter: "auto", headerRows: 1, quoted: true });
const mappings = defaultColumnMappings(table.headers, existingFields);
const preview = buildRecipientImportPreview(table, mappings, {
existingFields,
existingEntries: [{ id: "alice" }],
valueSeparators: ",;|"
});
assert(table.delimiter === ";", "semicolon delimiter is detected");
assert(table.headerRows.length === 1 && table.dataRows.length === 3, "header and data rows are separated");
assert(mappingFor(mappings, 0).kind === "to", "email columns map to To");
assert(mappingFor(mappings, 2).kind === "field" && mappingFor(mappings, 2).fieldName === "customer_id", "existing fields are detected");
assert(mappingFor(mappings, 3).kind === "new_field" && mappingFor(mappings, 3).newFieldName === "contract_no", "explicit new field columns are detected");
const profile = createRecipientMappingProfile({
id: "profile-1",
name: "ERP export",
table,
mappings,
parseOptions: { headerRows: 1, quoted: true },
valueSeparators: ",;|",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z"
});
const exactProfileMatches = matchRecipientMappingProfiles(table, [profile]);
assert(exactProfileMatches[0]?.mode === "ordered" && exactProfileMatches[0].score === 1, "ordered header fingerprints match exactly");
const reorderedTable = buildImportTable(
[
"name;email;active;attachment_pattern;field.contract_no;customer_id",
"Alice;alice@example.org;yes;${customer_id}.pdf;CN-1;C-1"
].join("\n"),
{ delimiter: "auto", headerRows: 1, quoted: true }
);
const reorderedMatches = matchRecipientMappingProfiles(reorderedTable, [profile]);
assert(reorderedMatches[0]?.mode === "unordered", "unordered header fingerprints match reordered exports");
const reorderedMappings = applyRecipientMappingProfile(reorderedTable, profile, existingFields);
assert(mappingFor(reorderedMappings, 0).kind === "name", "reordered profiles apply mappings by header");
assert(mappingFor(reorderedMappings, 1).kind === "to", "email mapping follows the reordered header");
assert(mappingFor(reorderedMappings, 4).kind === "new_field" && mappingFor(reorderedMappings, 4).newFieldName === "contract_no", "new field mappings follow reordered headers");
const similarTable = buildImportTable(
[
"email;name;customer_id;attachment_pattern",
"alice@example.org;Alice;C-1;${customer_id}.pdf"
].join("\n"),
{ delimiter: "auto", headerRows: 1, quoted: true }
);
const similarMatches = matchRecipientMappingProfiles(similarTable, [profile]);
assert(similarMatches[0]?.mode === "similar", "similar profiles are suggested when headers changed");
assert(preview.validCount === 1, "one valid row is detected");
assert(preview.invalidCount === 2, "invalid rows are detected");
assert(preview.patternCount === 3, "all pattern cells are counted before invalid rows are skipped");
assert(preview.fieldNamesToCreate.length === 1 && preview.fieldNamesToCreate[0] === "contract_no", "mapped new fields are created");
assert(preview.rows[0].id === "alice-2", "import ids avoid existing entries");
assert(preview.rows[0].patterns.length === 2, "pattern cells split on configured separators");
const multiTable = buildImportTable('to;name\n"Alice <alice@example.org>|bob@example.org";Team', { delimiter: "auto", headerRows: 1, quoted: true });
const multiPreview = buildRecipientImportPreview(multiTable, defaultColumnMappings(multiTable.headers, []), {
existingFields: [],
valueSeparators: "|"
});
assert(multiPreview.validCount === 1, "multi-address cells can produce a valid recipient");
assert(multiPreview.rows[0].addresses.to.length === 2, "To columns can contain multiple addresses");
assert(multiPreview.rows[0].addresses.to[0].name === "Alice", "display names are parsed from address cells");
const draft = {
fields: [{ name: "customer_id", label: "Customer ID", type: "string", required: false, can_override: true }],
entries: { inline: [{ id: "alice", to: [{ email: "old@example.org" }] }], source: { type: "csv" }, mapping: { id: "id" } }
};
const imported = materializeRecipientImport(draft, preview, { mode: "append", attachmentBasePath: { id: "bp-1", path: "invoices" } });
const entries = asRecord(imported.entries);
const inline = entries.inline as Record<string, unknown>[];
const fields = imported.fields as Record<string, unknown>[];
const importedEntry = inline[1];
const attachments = importedEntry.attachments as Record<string, unknown>[];
assert(inline.length === 2, "append keeps existing entries");
assert(entries.source === undefined && entries.mapping === undefined, "external source metadata is removed for inline imports");
assert(fields.some((field) => field.name === "contract_no"), "new field definition is added");
assert(importedEntry.email === "alice@example.org", "entry email is materialized");
assert(attachments.length === 2, "valid row patterns become attachment rules");
assert(attachments[0].base_path_id === "bp-1" && attachments[0].base_dir === "invoices", "attachment rules use the selected source");
assert(attachments[0].file_filter === "${customer_id}.pdf", "field placeholders remain in imported patterns");
const distributionExpansion: DistributionListExpansionSnapshot = {
source: {
id: "list-1",
tenant_id: "tenant-1",
name: "Residents",
revision_id: "revision-3",
revision: 3,
definition_hash: "definition-hash-3",
definition_kind: "static",
status: "active",
entry_count: 2,
read_only: false,
stale: false,
parameters: [],
provenance: {},
metadata: {}
},
request: { requested_channels: ["email", "postal"] },
recipients: [
{
recipient_key: "contact:ada",
display_name: "Ada Lovelace",
status: "usable",
channels: [
{
channel: "email",
target: "ada@example.org",
target_key: "email:ada@example.org",
status: "usable",
preferred: true,
decision_provenance: { policy: "allow" }
},
{
channel: "postal",
target: "Example Street 1",
target_key: "postal:ada",
status: "usable",
preferred: false,
decision_provenance: { policy: "fallback" }
}
],
source_entry_ids: ["entry-addresses"],
explanations: [],
attributes: { district: "north" },
provenance: { provider: "addresses" }
},
{
recipient_key: "contact:postal",
display_name: "Postal only",
status: "usable",
channels: [
{
channel: "postal",
target: "Example Street 2",
target_key: "postal:only",
status: "usable",
preferred: true,
decision_provenance: { policy: "allow" }
}
],
source_entry_ids: ["entry-postal"],
explanations: [],
attributes: {},
provenance: {}
}
],
excluded: [
{
recipient_key: "contact:suppressed",
display_name: "Suppressed",
status: "suppressed",
channels: [],
source_entry_ids: ["entry-addresses"],
explanations: [{ code: "opt_out", message: "Recipient opted out.", severity: "warning", provenance: {} }],
attributes: {},
provenance: {}
}
],
diagnostics: [],
provider_evidence: [],
expansion_hash: "expansion-hash-3",
generated_at: "2026-08-02T10:00:00Z",
snapshot_id: "snapshot-3",
stale: false,
truncated: false
};
const distributionDraft = materializeDistributionListExpansion(draft, distributionExpansion, "replace");
const distributionEntries = asRecord(distributionDraft.entries);
const distributionInline = distributionEntries.inline as Record<string, unknown>[];
const firstDistributionSource = asRecord(distributionInline[0].distribution_source);
const secondDistributionSource = asRecord(distributionInline[1].distribution_source);
const distributionImports = distributionEntries.imports as Record<string, unknown>[];
assert(distributionInline.length === 2, "all included Distribution List recipients are frozen into Campaign");
assert(distributionInline[0].active === true && distributionInline[0].email === "ada@example.org", "one preferred route is selected without duplicating channels");
assert(distributionInline[1].active === false, "postal-only recipients are retained but not sent through the mail-only path");
assert(firstDistributionSource.snapshot_id === "snapshot-3" && firstDistributionSource.list_revision_id === "revision-3", "recipient rows retain snapshot and list revision evidence");
assert((secondDistributionSource.source_entry_ids as string[])[0] === "entry-postal", "recipient rows retain source entry references");
assert(distributionImports[0].source_type === "distribution_list", "Campaign stores Distribution List import provenance");
assert((asRecord(distributionImports[0].source_provenance).exclusions as unknown[]).length === 1, "excluded recipients remain in immutable import evidence");
assert(distributionListDrift(distributionEntries.imports, [{ id: "list-1", revision: 4, revision_id: "revision-4", definition_hash: "definition-hash-4" }]).length === 1, "list revision drift is detected without changing frozen recipients");
const explicitlyRoutedDraft = materializeDistributionListExpansion(
draft,
distributionExpansion,
"replace",
{
"contact:ada": {
primaryTargetKey: "email:ada@example.org",
fallbackTargetKey: "postal:ada"
},
"contact:postal": { primaryTargetKey: "postal:only" }
}
);
const explicitlyRoutedEntries = asRecord(explicitlyRoutedDraft.entries).inline as Record<string, unknown>[];
assert(explicitlyRoutedEntries[0].channel_policy === "mail_then_print", "an explicit postal fallback is frozen into the delivery policy");
assert(asRecord(explicitlyRoutedEntries[0].print_target).target_key === "postal:ada", "the exact printable fallback target is retained");
assert(explicitlyRoutedEntries[1].active === true && explicitlyRoutedEntries[1].channel_policy === "print", "print-only recipients remain active without a Mail address");
assert(asRecord(explicitlyRoutedEntries[1].distribution_source).route_reason === "explicit_user_selection", "route provenance records the user decision");
void runXlsxImportAssertions();
async function runXlsxImportAssertions(): Promise<void> {
const workbookBuffer = base64ToArrayBuffer("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");
const workbookSheets = await xlsxSheetsFromArrayBuffer(workbookBuffer);
assert(workbookSheets[0]?.name === "Recipients", "xlsx sheets are listed");
const xlsxTable = buildImportTableFromRows(workbookSheets[0].rows, { headerRows: 1 });
const xlsxMappings = defaultColumnMappings(xlsxTable.headers, []);
const xlsxPreview = buildRecipientImportPreview(xlsxTable, xlsxMappings, { existingFields: [], valueSeparators: ",;|" });
assert(xlsxPreview.validCount === 1, "xlsx rows become import preview rows");
assert(mappingFor(xlsxMappings, 2).kind === "new_field" && mappingFor(xlsxMappings, 2).newFieldName === "department", "xlsx headers use the same mapping guesser");
const provenance = createRecipientImportProvenance({
preview: xlsxPreview,
mappings: xlsxMappings,
mode: "replace",
sourceType: "xlsx",
filename: "recipients.xlsx",
sheetName: "Recipients",
headerRows: 1,
valueSeparators: ",;|"
});
const importedWithProvenance = materializeRecipientImport(draft, xlsxPreview, { mode: "replace", provenance });
const provenanceEntries = asRecord(importedWithProvenance.entries);
const imports = provenanceEntries.imports as Record<string, unknown>[];
assert(imports.length === 1, "recipient import provenance is stored with entries");
assert(imports[0].source_type === "xlsx" && imports[0].sheet_name === "Recipients", "xlsx provenance records source details");
assert((imports[0].mapping as Record<string, unknown>[]).length === xlsxTable.headers.length, "provenance stores column mapping");
}