Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:26 +02:00
parent 1b32427813
commit c51fc180fb
111 changed files with 6905 additions and 980 deletions
@@ -69,6 +69,20 @@ const attachmentPreview = fs.readFileSync(
path.join(sourceRoot, "features/campaigns/review/AttachmentLinkingPreview.tsx"),
"utf8",
);
const archivePolicyNotice = fs.readFileSync(
path.join(sourceRoot, "features/campaigns/components/CampaignArchiveEncryptionPolicyNotice.tsx"),
"utf8",
);
for (const page of ["AttachmentsDataPage.tsx", "GlobalSettingsPage.tsx"]) {
const value = fs.readFileSync(path.join(sourceRoot, "features/campaigns", page), "utf8");
assert.match(value, /<CampaignArchiveEncryptionPolicyNotice/, `${page} must make archive policy configuration discoverable`);
}
assert.match(archivePolicyNotice, /\/admin\?section=system-campaign-archive-encryption/, "Campaign must link to the actual system Legacy ZipCrypto setting");
assert.match(archivePolicyNotice, /\/admin\?section=tenant-campaign-archive-encryption/, "Campaign must expose the tenant ceiling as well as the system switch");
assert.match(archivePolicyNotice, /policyInstalled && hasScope\(auth, "admin:policies:read"\)/, "Policy admin actions stay conditional on module and permission availability");
assert.match(archivePolicyNotice, /campaigns:archive:use_legacy_zipcrypto/, "The dedicated weak-encryption permission stays explicit");
assert.match(archivePolicyNotice, /setPolicy\(UNAVAILABLE_ARCHIVE_POLICY\)/, "Reloading policy must fail closed while its current state is unknown");
assert.match(archivePolicyNotice, /setRefresh\(\(value\) => value \+ 1\)/, "Operators must be able to reload policy after an administrator changes it");
assert.ok(
jsonView.includes('DismissibleAlert tone="warning" dismissible={false}'),
"The full Campaign JSON projection must retain an explicit privacy warning",
@@ -56,7 +56,7 @@ assert(overviewPage.includes("drilldown={{"), "campaign overview metrics expose
assert(overviewPage.includes("openSection(\"recipients\")"), "the recipient count opens its owning collection");
assert(attachmentsPage.includes('revealAttachmentSection("campaign-global-attachments")'), "attachment counts reveal their underlying editor section");
assert(attachmentsPage.includes("openRecipients"), "per-recipient attachment counts open recipient details");
assert(reviewPage.includes("showBuiltDetails({ validation:"), "review counts apply a relevant table filter");
assert(reviewPage.includes("messageState: 'list:[\"needs_review\",\"blocked\"]'"), "review counts apply the unified message-state table filter");
assert(reviewPage.includes('revealReviewElement("campaign-attachment-preview")'), "attachment-result counts reveal the evidence preview");
assert(!page.includes("{report.population.denominator_definition}"), "the known denominator contract uses a localized UI explanation");
assert(!page.includes("{report.privacy.rule}"), "the known privacy contract uses a localized UI explanation");
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { builtMessageState } from "../src/features/campaigns/review/builtMessageState.ts";
const noReviews = new Set<string>();
const row = (validation_status: string, fields: Record<string, unknown> = {}) => ({
id: "job-1", review_key: "recipient-key", build_status: "built", validation_status, ...fields
});
test("single display state distinguishes automatic and explicitly accepted readiness", () => {
assert.deepEqual(builtMessageState(row("ready"), 0, noReviews), { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_automatic" });
assert.deepEqual(builtMessageState(row("needs_review"), 0, noReviews), { state: "needs_review", explanationLabel: "i18n:govoplan-campaign.message_state_individual_pending" });
assert.deepEqual(builtMessageState(row("needs_review"), 0, new Set(["recipient-key"])), { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_individually_accepted" });
assert.equal(builtMessageState(row("needs_review", { reviewed: true }), 0, noReviews).state, "ready");
});
test("warnings need the final acknowledgement, not a legacy view marker", () => {
assert.deepEqual(builtMessageState(row("warning", { reviewed: true }), 0, new Set(["recipient-key"])), {
state: "needs_review", explanationLabel: "i18n:govoplan-campaign.message_state_warning_pending"
});
assert.deepEqual(builtMessageState(row("warning"), 0, noReviews, true), {
state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_warning_accepted"
});
});
test("hard blockers and failed builds never become ready through a review marker", () => {
for (const message of [row("blocked", { reviewed: true }), row("needs_review", { reviewed: true, issues: [{ behavior: "block" }] }),
row("ready", { build_status: "failed" }), row("ready", { build_status: "pending" }), row("unknown")]) {
assert.equal(builtMessageState(message, 0, new Set(["recipient-key"]), true).state, "blocked");
}
});
test("intentionally excluded or inactive messages remain excluded, including unbuilt ones", () => {
for (const status of ["excluded", "inactive"]) {
assert.equal(builtMessageState(row(status, { build_status: "failed", reviewed: true }), 0, noReviews, true).state, "excluded");
}
});
test("complete review does not invent a missing individual decision", () => {
assert.equal(builtMessageState(row("needs_review"), 0, noReviews, true).state, "needs_review");
assert.equal(builtMessageState(row("needs_review", { entry_id: "legacy-entry", review_key: undefined }), 0, new Set(["legacy-entry"])).state, "ready");
});
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { BULK_MESSAGE_REVIEW_LIMIT, bulkMessageReviewGroups, bulkReviewIssueLabel } from "../src/features/campaigns/review/bulkMessageReview.ts";
function job(id: string, patch: Record<string, unknown> = {}) {
return { id, build_status: "built", validation_status: "needs_review", recipient_email: `${id}@example.test`,
review_decision: { eligible: true, category_key: "attachments-empty", reason_required: true, issue_codes: ["attachment_missing"] }, ...patch };
}
test("bulk review trusts explicit backend eligibility, never warning status or expected outcomes", () => {
const groups = bulkMessageReviewGroups([
job("eligible"), job("missing", { review_decision: undefined }),
job("denied", { review_decision: { eligible: false, category_key: "attachments-empty" } }),
job("excluded", { validation_status: "excluded" }), job("warning", { validation_status: "warning" }),
job("blocked", { validation_status: "blocked" }), job("unbuilt", { build_status: "failed" }),
job("reviewed", { reviewed: true }), job("hard-issue", { issues: [{ behavior: "block" }] }),
job("empty-category", { review_decision: { eligible: true, category_key: "" } }), job(""),
]);
assert.deepEqual(groups.map((group) => group.messages.map((message) => message.jobId)), [["eligible"]]);
});
test("bulk grouping preserves full server category and exact stable deduplicated IDs", () => {
const groups = bulkMessageReviewGroups([
job("second"), job("first"), job("first"),
job("other", { review_decision: { eligible: true, category_key: "attachments-count", reason_required: false, issue_codes: ["attachment_count", "attachment_empty", "attachment_count"] } }),
]);
assert.equal(groups.length, 2);
assert.deepEqual(groups.find((group) => group.categoryKey === "attachments-empty")?.messages.map((message) => message.jobId), ["first", "second"]);
assert.equal(groups.find((group) => group.categoryKey === "attachments-empty")?.reasonRequired, true);
assert.deepEqual(groups.find((group) => group.categoryKey === "attachments-count")?.issueCodes, ["attachment_count", "attachment_empty"]);
});
test("group totals remain truthful beyond the bounded selection size", () => {
const groups = bulkMessageReviewGroups(Array.from({ length: 205 }, (_, index) => job(`job-${String(index).padStart(3, "0")}`)));
assert.equal(BULK_MESSAGE_REVIEW_LIMIT, 200);
assert.equal(groups[0].messages.length, 205);
assert.equal(groups[0].messages.slice(0, BULK_MESSAGE_REVIEW_LIMIT).length, 200);
});
test("category labels are translated descriptions rather than technical codes", () => {
assert.equal(bulkReviewIssueLabel("missing_optional_attachment"), "i18n:govoplan-campaign.bulk_review_missing_optional");
assert.equal(bulkReviewIssueLabel("missing_attachment_coverage"), "i18n:govoplan-campaign.bulk_review_no_attachments");
assert.equal(bulkReviewIssueLabel("duplicate_attachment_reuse"), "i18n:govoplan-campaign.bulk_review_shared_attachment");
assert.equal(bulkReviewIssueLabel("future_condition"), "i18n:govoplan-campaign.bulk_review_other_category");
});
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
const ts = require("typescript");
function evaluate(relativePath, imports = () => ({})) {
const filename = fileURLToPath(new URL(relativePath, import.meta.url));
const source = fs.readFileSync(filename, "utf8");
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const exports = {};
vm.runInNewContext(compiled, { exports, require: imports }, { filename });
return exports;
}
const catalogue = evaluate("../src/i18n/generatedTranslations.ts");
// Evaluate the actual module descriptor; unrelated page rendering and optional
// capability imports are inert. The real translation catalogue is not mocked.
const { campaignModule } = evaluate("../src/module.ts", (name) => {
if (name === "react") return { lazy: () => () => null };
if (name === "./i18n/generatedTranslations") return catalogue;
return {};
});
const sections = campaignModule.uiCapabilities["admin.sections"].sections;
assert.equal(sections.length, 2);
for (const section of sections) {
assert.equal(campaignModule.translations.en[section.label], "Campaign delivery");
assert.equal(campaignModule.translations.de[section.label], "Campaign-Versand");
assert.ok(campaignModule.viewSurfaces.some((surface) => surface.id === section.surfaceId));
}
assert.equal(sections[0].id, "system-campaign-delivery");
assert.deepEqual(Array.from(sections[0].allOf), ["system:settings:read"]);
assert.equal(sections[1].id, "tenant-campaign-delivery");
assert.deepEqual(Array.from(sections[1].allOf), ["admin:policies:read"]);
console.log("Campaign delivery administration labels resolve in the real EN/DE module catalogue.");
+32 -1
View File
@@ -1,4 +1,4 @@
import { campaignMailProfileReferenceOnly } from "../src/features/campaigns/utils/mailProfileReference";
import { campaignMailProfileListOptions, campaignMailProfileReferenceOnly, campaignMailReferencesUnchanged } from "../src/features/campaigns/utils/mailProfileReference";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
@@ -24,3 +24,34 @@ assert((source.server.smtp as Record<string, unknown>).password === "smtp-secret
const withoutProfile = campaignMailProfileReferenceOnly({ server: { smtp: { host: "legacy" } } });
assert(Object.keys(withoutProfile.server as Record<string, unknown>).length === 0, "Legacy settings without a profile must normalize to an empty reference object.");
const campaignChoices = campaignMailProfileListOptions("settings", "campaign-1");
assert(campaignChoices.campaignId === "campaign-1", "Mail settings must load profiles authorized for this campaign.");
assert(!campaignChoices.includeInactive, "Inactive profiles cannot become campaign delivery choices.");
const policyChoices = campaignMailProfileListOptions("policy", "campaign-1");
assert(policyChoices.campaignId === undefined, "Policy management must not restrict its catalogue to the current campaign allowance.");
assert(policyChoices.includeInactive, "Policy management may inspect its authorized inactive catalogue independently of delivery selection.");
const mixedLegacy = campaignMailProfileReferenceOnly({ server: {
mail_profile_id: "profile-1",
smtp_server_id: "server-smtp",
smtp_credential_id: "credential-smtp",
imap_server_id: "server-imap",
imap_credential_id: "credential-imap",
smtp: { password: "never-resubmit" },
imap: { password: "never-resubmit" },
credentials: { password: "never-resubmit" },
inherit_smtp_credentials: true,
inherit_imap_credentials: true
} });
assert(Object.keys(mixedLegacy.server as Record<string, unknown>).length === 5, "Migration must preserve all five stable Mail references and no legacy transport fields.");
assert(!JSON.stringify(mixedLegacy).includes("never-resubmit"), "Migration must not resubmit transport secrets.");
assert(campaignMailReferencesUnchanged(normalized, {
...normalized, attachments: { zip: { archives: [{ method: "aes" }] } }
}), "Unchanged public Mail references allow an independent archive policy repair.");
assert(campaignMailReferencesUnchanged({ server: {} }, { server: {}, template: { text: "Repair" } }), "A legacy version without a selected profile can still save unrelated repairs.");
assert(!campaignMailReferencesUnchanged(normalized, { server: { mail_profile_id: "profile-2" } }), "Selecting another Mail profile requires explicit migration.");
assert(!campaignMailReferencesUnchanged(normalized, { server: {} }), "Removing a Mail reference is not an unchanged save.");
assert(!campaignMailReferencesUnchanged(normalized, { server: { ...server, smtp: {} } }), "Inline transport cannot be submitted as an unrelated repair.");
assert(!campaignMailReferencesUnchanged(mixedLegacy, { server: { ...(mixedLegacy.server as object), smtp_credential_id: "other" } }), "Changing a Mail credential requires explicit migration.");
+8 -2
View File
@@ -41,6 +41,8 @@ assert(!params.toString().includes("unsupported") && !params.toString().includes
const shortcutFilters = {
smtp_accepted: { send: 'list:["smtp_accepted","sent"]' },
smtp_active: { send: 'list:["claimed","sending"]' },
smtp_queued: { send: 'list:["queued"]' },
failed: { send: 'list:["failed_temporary","failed_permanent"]' },
outcome_unknown: { send: 'list:["outcome_unknown"]' },
not_attempted: { send: 'list:["not_queued"]' },
@@ -48,6 +50,9 @@ const shortcutFilters = {
cancelled: { send: 'list:["cancelled"]' },
imap_appended: { imap: 'list:["appended"]' },
imap_failed: { imap: 'list:["failed"]' },
imap_pending: { imap: 'list:["pending"]' },
imap_active: { imap: 'list:["appending"]' },
imap_unknown: { imap: 'list:["outcome_unknown"]' },
imap_skipped: { imap: 'list:["skipped"]' }
} as const;
@@ -80,9 +85,10 @@ assert(reportSource.includes('setQuery("");') && reportSource.includes('setAppli
assert(reportSource.includes("deliveryOutcomeShortcuts.map") && reportSource.includes("imapOutcomeShortcuts.map"), "top-level delivery counts use one coherent shortcut model");
assert(reportSource.includes('<Button type="button" variant={active ? "primary" : "ghost"}'), "count shortcuts render the central Button directly");
assert(reportSource.includes('aria-pressed={active}'), "count shortcuts expose their selected state accessibly");
assert(reportSource.includes('"skipped",\n"queued"'), "SMTP skipped is a first-class report filter option");
const deliveryStatusSource = readFileSync("src/features/campaigns/utils/deliveryStatusOptions.ts", "utf8");
assert(deliveryStatusSource.includes('"not_queued", "skipped", "queued"'), "SMTP skipped is a first-class shared report filter option");
assert(reportSource.includes("i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00"), "the report explains excluded transport semantics through the bilingual catalog");
assert(reportSource.includes('return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7"'), "the new skipped filter and status badges use the localized label");
assert(reportSource.includes('import { SEND_STATUS_OPTIONS, IMAP_STATUS_OPTIONS, deliveryStatusLabel }'), "Report reuses shared localized delivery states");
assert(reportSource.includes("cards?.skipped ?? jobs.counts.send?.skipped"), "SMTP skipped has a separate report count");
assert(reportSource.includes("cards?.imap_skipped ?? jobs.counts.imap?.skipped"), "IMAP skipped has a separate report count");
assert(!reportSource.includes("setPage((value) => Math.max(1, value - 1))"), "the one-off report pager is removed in favor of the central DataGrid pager");
@@ -7,6 +7,9 @@ const here = dirname(fileURLToPath(import.meta.url));
const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8");
const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
const attachmentsPage = readFileSync(resolve(here, "../src/features/campaigns/AttachmentsDataPage.tsx"), "utf8");
const mailPage = readFileSync(resolve(here, "../src/features/campaigns/MailSettingsPage.tsx"), "utf8");
const migrationNotice = readFileSync(resolve(here, "../src/features/campaigns/components/LegacyMailMigrationNotice.tsx"), "utf8");
const draftEditor = readFileSync(resolve(here, "../src/features/campaigns/hooks/useCampaignDraftEditor.ts"), "utf8");
assert.match(guidance, /<ActionBlockerHint/);
assert.match(guidance, /<GuidedReviewList/);
@@ -17,9 +20,14 @@ assert.match(guidance, /target: destinationLabel/);
assert.match(guidance, /documentation=\{\{ topicId: documentationTopicId \}\}/);
assert.match(guidance, /campaigns\.workflow\.prepare-validate-and-build/);
assert.match(guidance, /campaigns\.workflow\.complete-review/);
assert.doesNotMatch(guidance, /showUnacknowledgedWarning|buildWarnings/,
"aggregate warnings must not create extra review requirements for expected or already accepted outcomes");
assert.match(page, /calculateBuildReviewProgress/);
assert.match(page, /<MetricCard[^>]+label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
assert.match(page, /showBuiltDetails\(\{ reviewed: 'list:\["no"\]' \}\)/);
assert.match(page, /showBuiltDetails\(\{ messageState: 'list:\["needs_review"\]' \}\)/);
assert.doesNotMatch(page, /showAllReviewJobs|visibleValidationIssues|attachmentReuseFindings\.slice\(0, 10\)/);
assert.match(page, /<ValidationDetails/);
assert.match(page, /<RepeatedFilesDetails/);
assert.match(page, /data-residual-file-policy/);
assert.match(page, /build\.residual_file_disposition/);
assert.match(page, /residual_file_count/);
@@ -36,3 +44,31 @@ for (const allowance of ["none", "same_recipient", "same_message"]) {
}
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);
assert.match(page, /readOnlyVersion = [^;]*mailProfileMigrationRequired/);
assert.match(page, /reloadAttachmentPreview = useCallback\(async[^]*?if \(!version\?\.id \|\| mailProfileMigrationRequired\)/);
assert.match(page, /<LegacyMailMigrationNotice campaignId=\{campaignId\} versionId=\{version\?\.id\}/);
assert.match(page, /state: mailProfileSelected && !mailProfileMigrationRequired \? "ready" : "blocked"/);
assert.match(page, /label: "Attachments",\s*\.\.\.attachmentDeliveryPreflight/);
assert.match(page, /inspectionComplete: inspectionSatisfied/);
assert.match(page, /use_reviewed_build: true/);
assert.match(page, /mailProfileMigrationRequired \? <DismissibleAlert[^>]*>\s*i18n:govoplan-campaign\.attachment_checks_await_mail_migration\s*<\/DismissibleAlert> : <AttachmentLinkingPreview/);
assert.match(mailPage, /campaignMailProfileListOptions\(view, campaignId\)/);
assert.doesNotMatch(mailPage, /Promise\.all/);
assert.match(mailPage, /const generation = \+\+profileLoadGeneration\.current/);
assert.match(mailPage, /if \(generation !== profileLoadGeneration\.current\) return;/);
assert.match(mailPage, /if \(generation === profileLoadGeneration\.current\) setProfilesLoading\(false\)/);
assert.match(mailPage, /if \(isPolicyView\) setPolicyProfiles\(\[\]\);\s*else setMailProfiles\(\[\]\)/);
assert.match(mailPage, /const canMigrate = migrationRequired && !locked && !saving && Boolean\(draft\) && Boolean\(selectedProfile\) && !profilesLoading/);
assert.doesNotMatch(mailPage, /const canMigrate = [^;]*dirty/);
assert.match(mailPage, /async function migrateMailProfile\(\)[^]*?await saveDraft\("manual"\)/);
assert.match(mailPage, /onMigrate=\{!isPolicyView && !locked/);
assert.match(migrationNotice, /useGuardedNavigate/);
assert.match(migrationNotice, /\?version=\$\{encodeURIComponent\(versionId\)\}/);
assert.match(migrationNotice, /disabled=\{!canMigrate \|\| busy\}/);
assert.match(draftEditor, /version\.mail_profile_migration_required && !additionalPayload\.migrate_legacy_mail_settings/);
assert.match(draftEditor, /!campaignMailReferencesUnchanged\(baseDraftRef\.current \?\? getCampaignJson\(version\), draftToSave\)/);
assert.match(draftEditor, /throw new Error\("i18n:govoplan-campaign\.legacy_mail_migration_required"\)/);
for (const editor of ["TemplateDataPage.tsx", "CampaignFieldsPage.tsx", "AttachmentsDataPage.tsx", "GlobalSettingsPage.tsx", "components/CampaignDraftPageScaffold.tsx"]) {
const source = readFileSync(resolve(here, "../src/features/campaigns", editor), "utf8");
assert.match(source, /version\?\.mail_profile_migration_required && <LegacyMailMigrationNotice/);
}
+21 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { calculateBuildReviewProgress } from "../src/features/campaigns/review/reviewProgress.ts";
import { attachmentDeliveryPreflight, calculateBuildReviewProgress } from "../src/features/campaigns/review/reviewProgress.ts";
test("separates blocking, individual, and group review work", () => {
assert.deepEqual(calculateBuildReviewProgress({
@@ -52,3 +52,23 @@ test("normalizes malformed counters without overstating progress", () => {
assert.equal(progress.required, 2);
assert.equal(progress.remaining, 0);
});
const acceptedAttachments = { migrationRequired: false, hasBuild: true, reviewLoaded: true,
blocking: 0, remaining: 0, inspectionComplete: true, missing: 14, ambiguous: 0 };
test("fourteen accepted missing matches do not ask again at final delivery preflight", () => {
assert.equal(attachmentDeliveryPreflight(acceptedAttachments).state, "ready");
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, inspectionComplete: false }).state, "ready");
});
test("expected policy omissions need no acceptance and pending review is not a hard attachment blocker", () => {
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, inspectionComplete: false, remaining: 0 }).state, "ready");
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, inspectionComplete: false, remaining: 14 }).state, "warning");
});
test("saved review cannot override real blockers, missing build evidence or legacy Mail migration", () => {
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, blocking: 1 }).state, "blocked");
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, migrationRequired: true }).state, "blocked");
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, reviewLoaded: false }).state, "info");
assert.equal(attachmentDeliveryPreflight({ ...acceptedAttachments, hasBuild: false }).state, "info");
});
@@ -1,5 +1,9 @@
import { campaignJsonForAttachmentPreview } from "../src/features/campaigns/utils/templatePreviewDraft";
import { checkContentLibraryCompatibility } from "../src/features/campaigns/utils/contentLibraryCompatibility";
import {
campaignVersionUpdateForRequest,
clientCampaignEditorState
} from "../src/features/campaigns/utils/editorState";
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
@@ -68,3 +72,44 @@ const compatibility = checkContentLibraryCompatibility([
assert(!compatibility.compatible, "missing or wrong-typed fields are incompatible");
assert(compatibility.missing.length === 1 && compatibility.missing[0].path === "global:case_reference", "namespaced missing fields are normalized");
assert(compatibility.incompatible.length === 1 && compatibility.incompatible[0].actual === "boolean", "type mismatches are reported");
// Ordinary saves must not echo the server-owned evidence returned by reads.
// This is the state that previously broke saves when leaving the Template page.
const loadedEditorState = {
created_from: "minimal_campaign",
field_overrides: { department: false },
opt_ins: { inline_guidance: true },
review_send: {
inspection_complete: true,
reviewed_message_keys: ["gabriele.fode", "fruzsina.molnar-gabor"],
issue_decisions: [],
updated_at: "2026-07-15T11:23:31.569257+00:00",
updated_by_user_id: "064fa105-9e69-4f29-8558-a7665eb219df"
},
approval_gate: { request_id: "approval-1", requested_by_user_id: "approver-1" },
smtp: { password: "legacy-secret" }
};
const clientState = clientCampaignEditorState(loadedEditorState);
assert(JSON.stringify(clientState) === JSON.stringify({
created_from: "minimal_campaign",
field_overrides: { department: false },
opt_ins: { inline_guidance: true }
}), "draft and merge metadata contains only client-owned fields");
const savePayload = {
editor_state: loadedEditorState,
base_revision: 7,
campaign_json: { template: { subject: "Updated template" } },
migrate_legacy_mail_settings: true
};
const requestPayload = campaignVersionUpdateForRequest(savePayload);
assert(JSON.stringify(requestPayload.editor_state) === JSON.stringify(clientState), "save, autosave, and fork requests omit review and approval evidence");
assert(!JSON.stringify(requestPayload).includes("legacy-secret"), "unknown legacy metadata is not resubmitted");
assert(requestPayload.base_revision === 7 && requestPayload.migrate_legacy_mail_settings, "concurrency and explicit migration controls are preserved");
assert(requestPayload.campaign_json === savePayload.campaign_json, "campaign content is not rewritten by metadata serialization");
assert(savePayload.editor_state.review_send.inspection_complete, "read response evidence is not mutated");
assert("approval_gate" in savePayload.editor_state, "read response approval evidence is preserved");
assert(Object.keys(clientCampaignEditorState(undefined)).length === 0, "new drafts need no metadata");
const omittedEditorState = { base_revision: 2, editor_state: undefined };
assert(campaignVersionUpdateForRequest(omittedEditorState) === omittedEditorState, "omitted metadata remains omitted");
const nullEditorState = { editor_state: null };
assert(campaignVersionUpdateForRequest(nullEditorState) === nullEditorState, "null metadata retains its unchanged-state semantics");
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { groupCampaignValidationIssues } from "../src/features/campaigns/review/validationIssueGroups.ts";
const cause = { code: "missing_required_attachment", severity: "warning", path: "/entries/recipient-1/attachments/0", message: "No file matched filter" };
const outcome = { code: "missing_attachment_coverage", severity: "info", path: "/entries/recipient-1", message: "Policy excludes this message without attachments" };
test("one grouped condition preserves the missing-rule cause and policy outcome regardless of order", () => {
for (const records of [[cause, outcome], [outcome, cause]]) {
const groups = groupCampaignValidationIssues(records);
assert.equal(groups.length, 1);
assert.deepEqual(groups[0].causes, [cause]);
assert.deepEqual(groups[0].outcomes, [outcome]);
assert.deepEqual(groups[0].evidence, records);
assert.equal(groups[0].severity, "warning");
}
});
test("different recipients, unmatched rule without coverage, and unrelated errors are never collapsed", () => {
const email = { code: "missing_email", severity: "error", path: "/entries/recipient-1", message: "No address" };
const other = { ...cause, path: "/entries/recipient-2/attachments/0" };
const groups = groupCampaignValidationIssues([cause, outcome, email, other]);
assert.equal(groups.length, 3);
assert.deepEqual(groups[1].evidence, [email]);
assert.deepEqual(groups[2].evidence, [other]);
});
test("multiple rule causes remain visible and a real block is not downgraded by an informational outcome", () => {
const blocked = { ...cause, severity: "error", path: "/entries/recipient-1/attachments/1" };
const [group] = groupCampaignValidationIssues([cause, blocked, outcome]);
assert.equal(group.severity, "error");
assert.equal(group.causes.length, 2);
assert.equal(group.evidence.length, 3);
});