322 lines
22 KiB
TypeScript
322 lines
22 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
async function install(page: Page, options: { rejectFirst?: boolean; conflictFirst?: boolean; stateMatrix?: boolean; holdSave?: Promise<void>; holdFirstJobs?: Promise<void>; rejectFirstJobs?: boolean; language?: "en" | "de"; readOnly?: boolean } = {}) {
|
|
const errors: string[] = [];
|
|
page.on("pageerror", error => { errors.push(error.message); console.error("Review fixture:", error.message); });
|
|
const writes: Record<string, any>[] = [];
|
|
const reads: string[] = [];
|
|
let revision = 1;
|
|
let complete = false;
|
|
let buildGeneration = 1;
|
|
let selectedVersionId = "review-version";
|
|
let jobRequests = 0;
|
|
const builds: Record<string, unknown>[] = [];
|
|
const publicBuildToken = () => selectedVersionId === "replacement-version" ? "replacement-build-reference"
|
|
: buildGeneration === 1 ? "opaque-build-reference" : `rebuilt-reference-${buildGeneration}`;
|
|
const subjectFor = (subject: string) => selectedVersionId === "replacement-version" ? `Replacement ${subject}`
|
|
: buildGeneration === 1 ? subject : `Rebuilt ${subject}`;
|
|
const decisions = new Map<string, Record<string, unknown>>();
|
|
const statuses = options.stateMatrix ? ["needs_review", "ready", "excluded", "warning", "blocked", "needs_review"] : ["needs_review", "needs_review", "excluded"];
|
|
if (options.stateMatrix) decisions.set("job-6", { job_id: "job-6", decision: "accept", reason: "Already reviewed this specific exception." });
|
|
const jobs = statuses.map((status, offset) => {
|
|
const index = offset + 1;
|
|
return { id: `job-${index}`, entry_id: `entry-${index}`, review_key: `entry-${index}`,
|
|
entry_index: index, recipient_email: `recipient-${index}@example.test`, subject: `Message ${index}`,
|
|
build_status: "built", validation_status: status, send_status: "not_queued",
|
|
resolved_recipients: { to: [{ email: `recipient-${index}@example.test` }] }, attachments: [],
|
|
issues: status === "ready" ? [] : [{ code: "missing_optional_attachment", behavior: status === "excluded" ? "drop" : status === "warning" ? "warn" : status === "blocked" ? "block" : "ask", severity: status === "blocked" ? "error" : "warning", source: "attachments", message: "Expected attachment was not found" }],
|
|
review_decision: { eligible: status === "needs_review", category_key: "attachment-group", reason_required: true, issue_codes: ["missing_optional_attachment"] }
|
|
}; });
|
|
const version = () => ({ id: selectedVersionId, campaign_id: "review-campaign", version_number: 1,
|
|
edit_revision: revision, strong_etag: `"${selectedVersionId}:${revision}"`, review_build_token: publicBuildToken(),
|
|
locked_at: "2026-09-07T09:00:00Z",
|
|
current_flow: "manual", current_step: "review", workflow_state: "built", is_complete: false,
|
|
updated_at: "2026-09-07T10:00:00Z", build_summary: { built_count: 2, needs_review_count: 2 }, validation_summary: { ok: true, warning_count: 14, issues: [] },
|
|
raw_json: { campaign: { name: "Review fixture" }, template: { subject: "Review", text: "Fixture message", html: "<p>Fixture message</p>" },
|
|
server: {}, entries: { inline: jobs.map(row => ({ id: row.entry_id, email: row.recipient_email, active: true })) } },
|
|
editor_state: { review_send: { review_build_token: publicBuildToken(), inspection_complete: complete,
|
|
reviewed_message_keys: [...decisions.keys()].map(id => `entry-${id.split("-")[1]}`), issue_decisions: [...decisions.values()] } }
|
|
});
|
|
const jobResponse = () => ({ jobs: jobs.map(row => ({ ...row, subject: subjectFor(row.subject), reviewed: decisions.has(row.id) })), page: 1, page_size: 200,
|
|
total: jobs.length, total_unfiltered: jobs.length, pages: 1, cursor: null, next_cursor: null, counts: {}, filtered_counts: {},
|
|
review: { blocking_count: options.stateMatrix ? 1 : 0, required_count: 2, bulk_acceptable_count: options.stateMatrix ? 1 : 0, reviewed_required_count: decisions.size, inspection_complete: complete } });
|
|
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
|
|
const request = route.request(); const url = new URL(request.url());
|
|
if (request.method() === "GET") {
|
|
reads.push(url.pathname);
|
|
if (url.pathname.endsWith("/workspace/delta")) {
|
|
selectedVersionId = url.searchParams.get("version_id") ?? "review-version";
|
|
return route.fulfill({ json: {
|
|
campaign: { id: "review-campaign", name: "Review fixture", current_version_id: selectedVersionId, status: "draft" },
|
|
versions: [version()], current_version: version(), summary: { cards: { jobs_total: jobs.length, sent: 0, failed: 0 }, status_counts: {}, attachments: { missing_configs: 14 } },
|
|
deleted: [], full: true, has_more: false, watermark: `w-${revision}`
|
|
} }); }
|
|
if (url.pathname.endsWith("/jobs")) {
|
|
jobRequests++;
|
|
const snapshot = jobResponse();
|
|
if (jobRequests === 1 && options.holdFirstJobs) {
|
|
// A stale response advertising another page must not start that
|
|
// follow-up request after the selected build has changed.
|
|
snapshot.pages = 2;
|
|
await options.holdFirstJobs;
|
|
}
|
|
if (jobRequests === 1 && options.rejectFirstJobs) return route.fulfill({ status: 503, json: { detail: "Fixture initial review load failed" } });
|
|
return route.fulfill({ json: snapshot });
|
|
}
|
|
if (/\/jobs\/job-/.test(url.pathname)) return route.fulfill({ json: { job: jobs.find(row => url.pathname.endsWith(row.id)), attempts: [] } });
|
|
if (url.pathname.endsWith(`/versions/${selectedVersionId}`)) return route.fulfill({ json: version() });
|
|
return route.fulfill({ json: {} });
|
|
}
|
|
if (url.pathname.endsWith("/attachments/preview")) return route.fulfill({ json: {
|
|
campaign_id: "review-campaign", version_id: "review-version", shared_file_count: 0,
|
|
rules: [], linkable_files: [], unused_shared_files: []
|
|
} });
|
|
if (request.method() === "POST" && url.pathname.endsWith("/review-state")) {
|
|
const body = request.postDataJSON(); writes.push(body);
|
|
if (options.holdSave) await options.holdSave;
|
|
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 503, json: { detail: "Fixture decision save unavailable" } });
|
|
if (options.conflictFirst && writes.length === 1) {
|
|
decisions.set("job-2", { job_id: "job-2", decision: "accept", reason: "Another reviewer checked this document.", actor_user_id: "other-reviewer" });
|
|
revision++;
|
|
return route.fulfill({ status: 409, json: { detail: "Another reviewer saved a decision. Inspect the refreshed progress and retry explicitly." } });
|
|
}
|
|
expect(body.merge_progress).toBe(true);
|
|
expect(body.build_token).toBe(publicBuildToken());
|
|
expect(body.base_revision).toBe(revision);
|
|
for (const decision of body.issue_decisions) decisions.set(decision.job_id, decision);
|
|
revision++; complete = body.inspection_complete;
|
|
return route.fulfill({ json: version() });
|
|
}
|
|
if (request.method() === "POST" && url.pathname.endsWith("/build")) {
|
|
builds.push(request.postDataJSON());
|
|
buildGeneration++; revision++; complete = false; decisions.clear();
|
|
return route.fulfill({ json: { built_count: 2, build_token: publicBuildToken() } });
|
|
}
|
|
return route.abort();
|
|
});
|
|
await page.goto(`/?campaign-review&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.holdFirstJobs ? "&race" : ""}`);
|
|
if (options.holdFirstJobs || options.rejectFirstJobs) await expect.poll(() => jobRequests).toBe(1);
|
|
else await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
|
|
return { writes, reads, errors, decisions, builds, get jobRequests() { return jobRequests; } };
|
|
}
|
|
|
|
async function openMessage(page: Page, index = 1) {
|
|
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
|
await expect(table.getByText(`Message ${index}`, { exact: true })).toBeVisible();
|
|
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).nth(index - 1).click();
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
return page.getByRole("dialog");
|
|
}
|
|
|
|
test("each acceptance persists immediately and advances, surviving reload before whole-review completion", async ({ page }) => {
|
|
const fixture = await install(page);
|
|
const dialog = await openMessage(page);
|
|
const readsBefore = fixture.reads.length;
|
|
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Recipient already received the document.");
|
|
expect(fixture.reads.length).toBe(readsBefore);
|
|
expect(fixture.writes).toHaveLength(0);
|
|
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
|
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
|
expect(fixture.writes).toHaveLength(1);
|
|
expect(fixture.writes[0].inspection_complete).toBe(false);
|
|
expect(fixture.writes[0].issue_decisions[0].reason).toBe("Recipient already received the document.");
|
|
expect(fixture.reads.length).toBe(readsBefore);
|
|
await page.reload();
|
|
await openMessage(page);
|
|
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
|
|
expect(fixture.writes).toHaveLength(1);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("failed review save keeps the reason and current message until explicit retry succeeds", async ({ page }) => {
|
|
const fixture = await install(page, { rejectFirst: true });
|
|
const dialog = await openMessage(page);
|
|
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Retain this reason.");
|
|
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
|
await expect(dialog.getByText(/Fixture decision save unavailable/)).toBeVisible();
|
|
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Retain this reason.");
|
|
expect(fixture.decisions.size).toBe(0);
|
|
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
|
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
|
expect(fixture.writes).toHaveLength(2);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("pending individual acceptance cannot duplicate, dismiss, or navigate before acknowledgement", async ({ page }) => {
|
|
let acknowledge!: () => void;
|
|
const fixture = await install(page, { holdSave: new Promise<void>((resolve) => { acknowledge = resolve; }) });
|
|
const dialog = await openMessage(page);
|
|
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Confirmed exception, waiting for durable save.");
|
|
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).evaluate((button: HTMLButtonElement) => {
|
|
button.click(); button.click();
|
|
});
|
|
await expect.poll(() => fixture.writes.length).toBe(1);
|
|
for (const navigation of await dialog.locator(".template-preview-nav button").all()) await expect(navigation).toBeDisabled();
|
|
for (const close of await dialog.getByRole("button", { name: /^(Close|Schließen)$/ }).all()) await expect(close).toBeDisabled();
|
|
await page.keyboard.press("Escape");
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
|
|
acknowledge();
|
|
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
|
expect(fixture.writes).toHaveLength(1);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("German individual review records its reason and skips intentional exclusions", async ({ page }) => {
|
|
const fixture = await install(page, { language: "de" });
|
|
let dialog = await openMessage(page);
|
|
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Das Dokument liegt dem Empfänger bereits vor.");
|
|
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
|
|
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
|
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Auch dieser Empfänger benötigt keinen weiteren Anhang.");
|
|
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
|
|
await expect(dialog).not.toBeVisible();
|
|
expect(fixture.writes).toHaveLength(2);
|
|
expect(fixture.decisions.has("job-3")).toBe(false);
|
|
await page.reload();
|
|
dialog = await openMessage(page);
|
|
await expect(dialog.getByText("Prüfentscheidung gespeichert. Sie können die Prüfung später fortsetzen.")).toBeVisible();
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("real review grouped acceptance persists only eligible messages with exact category guard", async ({ page }) => {
|
|
const fixture = await install(page);
|
|
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(1);
|
|
await page.getByRole("button", { name: "Accept similar review conditions", exact: true }).click();
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog.getByRole("checkbox")).toHaveCount(2);
|
|
await expect(dialog.getByRole("checkbox", { name: "recipient-3@example.test" })).toHaveCount(0);
|
|
await dialog.getByRole("textbox").fill("Both recipients have already received these documents.");
|
|
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
|
await expect(dialog).not.toBeVisible();
|
|
expect(fixture.writes).toHaveLength(1);
|
|
expect(fixture.writes[0]).toMatchObject({ merge_progress: true, build_token: "opaque-build-reference", base_revision: 1,
|
|
inspection_complete: false, decision_category_key: "attachment-group", reviewed_message_keys: ["entry-1", "entry-2"] });
|
|
expect(fixture.writes[0].issue_decisions).toEqual([
|
|
{ job_id: "job-1", decision: "accept", reason: "Both recipients have already received these documents." },
|
|
{ job_id: "job-2", decision: "accept", reason: "Both recipients have already received these documents." }
|
|
]);
|
|
await page.reload();
|
|
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
|
|
await openMessage(page, 2);
|
|
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
|
|
expect(fixture.decisions.has("job-3")).toBe(false);
|
|
await page.getByRole("dialog").getByRole("button", { name: /^(Close|Schließen)$/ }).last().click();
|
|
await page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ }).click();
|
|
await expect.poll(() => fixture.writes.length).toBe(2);
|
|
expect(fixture.writes[1].inspection_complete).toBe(true);
|
|
const attachmentsPreflight = page.locator(".deliverability-preflight-item").filter({ has: page.getByText(/^(Attachments|Anhänge)$/) });
|
|
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
|
|
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
|
|
await page.reload();
|
|
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
|
|
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("read-only campaign access permits inspection but no review decisions", async ({ page }) => {
|
|
const fixture = await install(page, { readOnly: true });
|
|
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ })).toBeDisabled();
|
|
const dialog = await openMessage(page);
|
|
const accept = dialog.getByRole("button", { name: "Accept and continue", exact: true });
|
|
if (await accept.count()) await expect(accept).toBeDisabled();
|
|
expect(fixture.writes).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("concurrent reviewer conflict refreshes evidence without replaying or overwriting their decision", async ({ page }) => {
|
|
const fixture = await install(page, { conflictFirst: true });
|
|
const dialog = await openMessage(page);
|
|
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Keep my independently inspected exception.");
|
|
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
|
await expect(dialog.getByText(/Another reviewer saved a decision/)).toBeVisible();
|
|
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Keep my independently inspected exception.");
|
|
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
|
|
expect(fixture.writes).toHaveLength(1);
|
|
expect(fixture.decisions.has("job-1")).toBe(false);
|
|
expect(fixture.decisions.get("job-2")?.reason).toBe("Another reviewer checked this document.");
|
|
expect(fixture.reads.filter(path => path.endsWith("/versions/review-version"))).toHaveLength(1);
|
|
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
|
await expect(dialog).not.toBeVisible();
|
|
expect(fixture.writes).toHaveLength(2);
|
|
expect(fixture.writes[1].base_revision).toBe(2);
|
|
expect(fixture.writes[1].issue_decisions).toEqual([{ job_id: "job-1", decision: "accept", reason: "Keep my independently inspected exception." }]);
|
|
expect(fixture.decisions.get("job-2")?.actor_user_id).toBe("other-reviewer");
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("one four-state filter distinguishes automatic, accepted, warning, blocked and excluded messages", async ({ page }) => {
|
|
const fixture = await install(page, { stateMatrix: true, language: "de" });
|
|
const section = page.locator("#campaign-built-message-details");
|
|
const table = section.getByRole("table");
|
|
const stateCells = table.locator('.data-grid-body-cell[data-column-id="messageState"]');
|
|
await expect(table.locator('[data-column-id="validation"]')).toHaveCount(0);
|
|
await expect(table.locator('[data-column-id="reviewed"]')).toHaveCount(0);
|
|
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Bereit", "Ausgeschlossen", "Prüfung erforderlich", "Blockiert", "Bereit"]);
|
|
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(1)).toContainText("keine manuelle Entscheidung erforderlich");
|
|
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(5)).toContainText("gespeicherter Prüfentscheidung");
|
|
await table.locator('.data-grid-header-cell[data-column-id="messageState"] .data-grid-filter-trigger').click();
|
|
const filter = page.locator(".data-grid-filter-popover");
|
|
await expect(filter.getByRole("checkbox")).toHaveCount(4);
|
|
for (const label of ["Bereit", "Prüfung erforderlich", "Blockiert", "Ausgeschlossen"]) await expect(filter.getByRole("checkbox", { name: label, exact: true })).toBeVisible();
|
|
await filter.getByRole("button", { name: "Alle abwählen", exact: true }).click();
|
|
await filter.getByRole("checkbox", { name: "Prüfung erforderlich", exact: true }).check();
|
|
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich"]);
|
|
await filter.getByRole("button", { name: /^(Close filter|Filter schließen)$/ }).click();
|
|
// The convenience action uses the very same derived state, adding blockers
|
|
// but never reintroducing accepted rows just because their build said ask.
|
|
await section.getByRole("button", { name: /^(Show review candidates only|Nur Prüfkandidaten anzeigen)$/ }).click();
|
|
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich", "Blockiert"]);
|
|
await section.getByRole("button", { name: /^(Show all messages|Alle Nachrichten anzeigen)$/ }).click();
|
|
await expect(stateCells).toHaveCount(6);
|
|
expect(fixture.writes).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("rebuilding a version loads its new review rows after the build busy state clears", async ({ page }) => {
|
|
const fixture = await install(page);
|
|
expect(fixture.jobRequests).toBe(1);
|
|
await page.getByRole("button", { name: /^(Build again|Erneut erstellen|Erneut bauen)$/ }).click();
|
|
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
|
await expect(table.getByText("Rebuilt Message 1", { exact: true })).toBeVisible();
|
|
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
|
|
expect(fixture.builds).toHaveLength(1);
|
|
expect(fixture.jobRequests).toBe(2);
|
|
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).first().click();
|
|
const dialog = page.getByRole("dialog");
|
|
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Reviewed the rebuilt evidence.");
|
|
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
|
await expect.poll(() => fixture.writes.length).toBe(1);
|
|
expect(fixture.writes[0]).toMatchObject({ build_token: "rebuilt-reference-2", base_revision: 2 });
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("a delayed old-version job response cannot replace the newly selected build", async ({ page }) => {
|
|
let releaseOld!: () => void;
|
|
const fixture = await install(page, { holdFirstJobs: new Promise<void>((resolve) => { releaseOld = resolve; }) });
|
|
await page.getByRole("button", { name: "Switch fixture version", exact: true }).click();
|
|
await expect(page).toHaveURL(/version=replacement-version/);
|
|
await expect.poll(() => fixture.reads.filter(path => path.endsWith("/workspace/delta")).length).toBeGreaterThanOrEqual(2);
|
|
releaseOld();
|
|
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
|
await expect(table.getByText("Replacement Message 1", { exact: true })).toBeVisible();
|
|
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
|
|
expect(fixture.jobRequests).toBe(2);
|
|
expect(fixture.writes).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test("an initial jobs failure stops automatic retries but a manual retry can recover", async ({ page }) => {
|
|
const fixture = await install(page, { rejectFirstJobs: true });
|
|
await expect(page.getByText(/Fixture initial review load failed/)).toBeVisible();
|
|
const retry = page.getByRole("button", { name: /^(Load review|Prüfung laden)$/ });
|
|
await expect(retry).toBeEnabled();
|
|
await page.waitForLoadState("networkidle");
|
|
expect(fixture.jobRequests).toBe(1);
|
|
await retry.click();
|
|
await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
|
|
expect(fixture.jobRequests).toBe(2);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|