test(news): gate deployment on an isolated real browser publication journey
This commit is contained in:
1 parent
60e49c1ec9
commit
7867bf6b72
11 files changed
+980
-2
No files matched your search
@@ -0,0 +1,299 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { type BrowserContext, expect, test } from "@playwright/test";
|
||||
import Redis from "ioredis";
|
||||
import mysql, {
|
||||
type ConnectionOptions,
|
||||
type RowDataPacket,
|
||||
} from "mysql2/promise";
|
||||
|
||||
interface Fixture {
|
||||
username: string;
|
||||
userId: number;
|
||||
password: string;
|
||||
database: ConnectionOptions;
|
||||
redis: { host: string; port: number; password: string };
|
||||
}
|
||||
|
||||
async function onlyLocalResources(context: BrowserContext, origin: string) {
|
||||
// External avatars/telemetry are outside this isolated hotel. All application
|
||||
// documents, scripts, forms, API requests and auth responses remain untouched.
|
||||
await context.route("**/*", (route) => {
|
||||
if (new URL(route.request().url()).origin === origin)
|
||||
return route.continue();
|
||||
return route.abort("blockedbyclient");
|
||||
});
|
||||
}
|
||||
|
||||
test("staff signs in, saves a draft, previews it and publishes to anonymous readers", async ({
|
||||
page,
|
||||
context,
|
||||
browser,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
if (!baseURL || !process.env.NEWS_E2E_FIXTURE)
|
||||
throw Error("Disposable fixture is required");
|
||||
const fixture = JSON.parse(
|
||||
await readFile(process.env.NEWS_E2E_FIXTURE, "utf8"),
|
||||
) as Fixture;
|
||||
const database = await mysql.createConnection({
|
||||
...fixture.database,
|
||||
timezone: "Z",
|
||||
charset: "utf8mb4",
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
});
|
||||
const redis = new Redis({
|
||||
...fixture.redis,
|
||||
maxRetriesPerRequest: 1,
|
||||
connectTimeout: 5_000,
|
||||
});
|
||||
const anonymous = await browser.newContext({
|
||||
baseURL,
|
||||
locale: "en-US",
|
||||
ignoreHTTPSErrors: true,
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
const reader = await anonymous.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
reader.on("pageerror", (error) => errors.push(error.message));
|
||||
const rows = async (sql: string, params: string[] = []) =>
|
||||
(await database.query<RowDataPacket[]>(sql, params))[0];
|
||||
const title = "Notizia browser: città e novità 🎉";
|
||||
const slug = "browser-news-real";
|
||||
const summary =
|
||||
"Una notizia creata e pubblicata attraverso il pannello reale.";
|
||||
const body = "È una prova reale: caffè, città e 🎉. Salvata dal browser.";
|
||||
let articleId = "";
|
||||
let submittedHtml = "";
|
||||
let draftRevision: string | null = null;
|
||||
try {
|
||||
await onlyLocalResources(context, baseURL);
|
||||
await onlyLocalResources(anonymous, baseURL);
|
||||
await redis.ping();
|
||||
await test.step("real authentication and staff ACL", async () => {
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page).toHaveURL(/\/login(?:\?|$)/);
|
||||
expect(await rows("SELECT user_id FROM website_login_logs")).toHaveLength(
|
||||
0,
|
||||
);
|
||||
await page
|
||||
.locator('input[autocomplete="username"]')
|
||||
.fill(fixture.username);
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.fill(fixture.password);
|
||||
await page
|
||||
.locator('input[autocomplete="current-password"]')
|
||||
.press("Enter");
|
||||
await expect(page).toHaveURL(/\/me(?:\?|$)/);
|
||||
const session = await context.request
|
||||
.get("/api/auth/session")
|
||||
.then((response) => response.json());
|
||||
expect(session.user).toMatchObject({
|
||||
id: String(fixture.userId),
|
||||
name: fixture.username,
|
||||
rank: 7,
|
||||
});
|
||||
expect(
|
||||
(await context.cookies()).some(
|
||||
(cookie) =>
|
||||
cookie.name.startsWith("__Secure-authjs.session-token") &&
|
||||
cookie.secure &&
|
||||
cookie.httpOnly,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await rows("SELECT user_id FROM website_login_logs")).toEqual([
|
||||
expect.objectContaining({ user_id: fixture.userId }),
|
||||
]);
|
||||
// The editor is below the highest occupied rank: access requires real ACL grants.
|
||||
expect(
|
||||
(await rows("SELECT MAX(rank) AS highest FROM users"))[0].highest,
|
||||
).toBe(9);
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page.locator('input[name="title"]')).toBeVisible();
|
||||
});
|
||||
await test.step("save the draft using the real rich text editor and server action", async () => {
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await form.locator('input[name="title"]').fill(title);
|
||||
await form.locator('input[name="slug"]').fill(slug);
|
||||
await form.locator('[name="shortStory"]').fill(summary);
|
||||
await form
|
||||
.locator('input[name="image"]')
|
||||
.fill("/assets/images/EnterHubbly.png");
|
||||
await form.locator('select[name="status"]').selectOption("draft");
|
||||
const editor = form
|
||||
.frameLocator("iframe.tox-edit-area__iframe")
|
||||
.locator('body[contenteditable="true"]');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.fill(body);
|
||||
await editor.press("End");
|
||||
await expect(form.locator('textarea[name="fullStory"]')).toHaveValue(
|
||||
/Salvata dal browser/,
|
||||
);
|
||||
submittedHtml = await form
|
||||
.locator('textarea[name="fullStory"]')
|
||||
.inputValue();
|
||||
await form.locator('button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(`${baseURL}/admin/articles`);
|
||||
const articles = await rows("SELECT * FROM website_articles");
|
||||
expect(articles).toHaveLength(1);
|
||||
expect(articles[0]).toMatchObject({
|
||||
title,
|
||||
slug,
|
||||
short_story: summary,
|
||||
status: "draft",
|
||||
user_id: fixture.userId,
|
||||
published_at: null,
|
||||
publish_at: null,
|
||||
});
|
||||
expect(articles[0].full_story).toBe(submittedHtml);
|
||||
articleId = String(articles[0].id);
|
||||
expect(await rows("SELECT kind, actor_id FROM cms_operations")).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "news.create",
|
||||
actor_id: fixture.userId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
await test.step("anonymous readers and the shared cache still see no published article", async () => {
|
||||
const response = await reader.goto(`/news/${slug}`);
|
||||
expect(response?.status()).toBeLessThan(500);
|
||||
// Next can stream not-found markup with HTTP 200; assert the actual 404 screen.
|
||||
await expect(reader.locator(".error-screen-code")).toHaveText("404");
|
||||
await expect(
|
||||
reader.getByRole("heading", { name: title, exact: true }),
|
||||
).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toEqual([]);
|
||||
expect(listing.meta.total).toBe(0);
|
||||
draftRevision = await redis.get("cms:news:revision");
|
||||
expect(draftRevision).not.toBeNull();
|
||||
expect(
|
||||
await redis.get(`news:${draftRevision}:article:v2:slug:${slug}`),
|
||||
).toBe("null");
|
||||
});
|
||||
await test.step("preview the persisted draft without publishing it", async () => {
|
||||
await page
|
||||
.locator(`a[href="/admin/articles/${articleId}"]`)
|
||||
.first()
|
||||
.click();
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await expect(form.locator('input[name="title"]')).toHaveValue(title);
|
||||
await expect(form.locator('select[name="status"]')).toHaveValue("draft");
|
||||
await expect(
|
||||
form.frameLocator("iframe.tox-edit-area__iframe").locator("body"),
|
||||
).toContainText(body);
|
||||
await form.getByRole("button", { name: "Preview", exact: true }).click();
|
||||
const preview = page.getByRole("dialog").frameLocator("iframe");
|
||||
await expect(
|
||||
preview.getByRole("heading", { name: title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(preview.locator("body")).toContainText(summary);
|
||||
await expect(preview.locator("body")).toContainText(body);
|
||||
expect(await rows("SELECT status FROM website_articles")).toEqual([
|
||||
{ status: "draft" },
|
||||
]);
|
||||
expect(
|
||||
await rows("SELECT id FROM website_article_revisions"),
|
||||
).toHaveLength(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
await test.step("publish once and persist revision, audit and delivery intent", async () => {
|
||||
const form = page.locator('form:has(input[name="title"])');
|
||||
await form.locator('select[name="status"]').selectOption("published");
|
||||
await form.locator('button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(`${baseURL}/admin/articles`);
|
||||
const articles = await rows("SELECT * FROM website_articles");
|
||||
expect(articles).toHaveLength(1);
|
||||
expect(articles[0]).toMatchObject({
|
||||
status: "published",
|
||||
user_id: fixture.userId,
|
||||
slug,
|
||||
title,
|
||||
});
|
||||
expect(articles[0].published_at).toBeInstanceOf(Date);
|
||||
const revisions = await rows(
|
||||
"SELECT article_id, user_id, payload FROM website_article_revisions",
|
||||
);
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(String(revisions[0].article_id)).toBe(articleId);
|
||||
expect(Number(revisions[0].user_id)).toBe(fixture.userId);
|
||||
expect(JSON.parse(revisions[0].payload)).toMatchObject({
|
||||
title,
|
||||
status: "draft",
|
||||
});
|
||||
const audit = await rows(
|
||||
"SELECT user_id, `before`, `after` FROM admin_audit_log WHERE target='news'",
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0].user_id).toBe(fixture.userId);
|
||||
expect(JSON.parse(audit[0].before).status).toBe("draft");
|
||||
expect(JSON.parse(audit[0].after).status).toBe("published");
|
||||
const operations = await rows(
|
||||
"SELECT kind, actor_id, result_json FROM cms_operations ORDER BY kind",
|
||||
);
|
||||
expect(operations.map((operation) => operation.kind)).toEqual([
|
||||
"news.create",
|
||||
"news.update",
|
||||
]);
|
||||
for (const operation of operations) {
|
||||
expect(operation.actor_id).toBe(fixture.userId);
|
||||
expect(JSON.parse(operation.result_json)).toMatchObject({ ok: true });
|
||||
}
|
||||
const effects = await rows("SELECT topic FROM cms_outbox");
|
||||
expect(effects).toEqual([
|
||||
{ topic: "news.refresh" },
|
||||
{ topic: "news.refresh" },
|
||||
]);
|
||||
expect(await redis.get("cms:news:revision")).not.toBe(draftRevision);
|
||||
});
|
||||
await test.step("anonymous pages and API read the newly published content", async () => {
|
||||
const response = await reader.reload();
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(
|
||||
reader.getByRole("heading", { name: title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(reader.locator(".article-body")).toContainText(body);
|
||||
await expect(reader.locator(".error-screen-code")).toHaveCount(0);
|
||||
const listing = await anonymous.request
|
||||
.get("/api/articles")
|
||||
.then((response) => response.json());
|
||||
expect(listing.data).toHaveLength(1);
|
||||
expect(listing.data[0]).toMatchObject({
|
||||
id: articleId,
|
||||
title,
|
||||
slug,
|
||||
shortStory: summary,
|
||||
});
|
||||
expect(listing.meta.total).toBe(1);
|
||||
const revision = await redis.get("cms:news:revision");
|
||||
const cached = await redis.get(
|
||||
`news:${revision}:article:v2:slug:${slug}`,
|
||||
);
|
||||
expect(JSON.parse(cached ?? "null")).toMatchObject({
|
||||
id: articleId,
|
||||
title,
|
||||
slug,
|
||||
});
|
||||
expect(
|
||||
(await anonymous.cookies()).some((cookie) =>
|
||||
cookie.name.includes("session-token"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
} finally {
|
||||
if (errors.length)
|
||||
await testInfo.attach("browser-errors", {
|
||||
body: JSON.stringify(errors, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await anonymous.close();
|
||||
await database.end();
|
||||
redis.disconnect();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user