test: cover activity privacy and moderation e2e
This commit is contained in:
parent
f8412ee090
commit
cf43030104
12
README.md
12
README.md
|
|
@ -150,10 +150,14 @@ two worker replicas:
|
|||
```
|
||||
|
||||
On its first run it generates `.env.e2e` with independent random local secrets
|
||||
and mode `0600`. The suite registers two users through real Mailpit messages,
|
||||
completes an urgent medicine request through matching, realtime chat, handover,
|
||||
and double-blind reviews, and checks public/authentication boundaries. It
|
||||
retains traces, screenshots, video, and Compose logs under the ignored
|
||||
and mode `0600`. The suite registers users through real Mailpit messages and
|
||||
uses the audited one-time bootstrap command inside only the isolated E2E
|
||||
database to create its moderator. It covers public/authentication boundaries;
|
||||
the urgent medicine flow through matching, realtime chat, handover, and
|
||||
double-blind reviews; and the Activity flow through join approval, private
|
||||
group chat, message-scoped reporting, blocking, privacy defaults, an unverified
|
||||
social link, category moderation, report resolution, and account restriction.
|
||||
It retains traces, screenshots, video, and Compose logs under the ignored
|
||||
`output/e2e/` directory on failure. The exact E2E project, database volume, and
|
||||
networks are removed automatically; the normal `who_need_help` Compose project
|
||||
is not recreated.
|
||||
|
|
|
|||
|
|
@ -29,9 +29,15 @@ The goal remains open while any row lacks reproducible local evidence.
|
|||
two-user medicine flow: Mailpit confirmation, category-driven request,
|
||||
matching, bidirectional realtime chat, handover verification, bilateral
|
||||
completion, and double-blind review reveal.
|
||||
- A separate browser scenario passes Activity creation, participant approval,
|
||||
bidirectional private group chat, a report scoped to the selected message,
|
||||
block/unblock, inherited location privacy, manual social-link removal,
|
||||
category-proposal rejection, report resolution, account restriction, and
|
||||
enforcement of that restriction. Its administrator is created in the
|
||||
isolated database through the existing audited one-time bootstrap command.
|
||||
- Browser console errors, page errors, and unexpected failed requests are
|
||||
test failures. Playwright traces, screenshots, video, JSON/HTML reports, and
|
||||
Compose logs are retained in ignored output on failure.
|
||||
- Activity, moderation, privacy/error paths, Android instrumentation, and the
|
||||
remaining rows above are still pending; this document is not a completion
|
||||
claim.
|
||||
- Android instrumentation and the remaining rows above are still pending.
|
||||
Browser accessibility, responsive, offline, and reconnect checks remain part
|
||||
of their respective later rows; this document is not a completion claim.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ WORKDIR /work
|
|||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY playwright.config.ts ./
|
||||
COPY playwright.config.ts playwright.bootstrap.config.ts ./
|
||||
COPY tests tests
|
||||
COPY setup setup
|
||||
|
||||
CMD ["npx", "playwright", "test"]
|
||||
|
|
|
|||
28
e2e/playwright.bootstrap.config.ts
Normal file
28
e2e/playwright.bootstrap.config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.BASE_URL;
|
||||
|
||||
if (!baseURL) {
|
||||
throw new Error("BASE_URL is required");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./setup",
|
||||
outputDir: "./output/bootstrap-test-results",
|
||||
workers: 1,
|
||||
forbidOnly: true,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
reporter: [["line"]],
|
||||
use: {
|
||||
baseURL,
|
||||
...devices["Desktop Chrome"],
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 20_000,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
});
|
||||
18
e2e/setup/admin-bootstrap.spec.ts
Normal file
18
e2e/setup/admin-bootstrap.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { test } from "@playwright/test";
|
||||
import { captureBrowserFailures, registerAndConfirm } from "../tests/helpers";
|
||||
|
||||
test("create the confirmed account used by the existing admin bootstrap", async ({
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
const admin = await registerAndConfirm(
|
||||
browser,
|
||||
request,
|
||||
"e2e-admin@example.invalid",
|
||||
"E2E Administrator",
|
||||
);
|
||||
const assertNoBrowserFailures = captureBrowserFailures(admin.page);
|
||||
|
||||
assertNoBrowserFailures();
|
||||
await admin.context.close();
|
||||
});
|
||||
200
e2e/tests/activity-moderation.spec.ts
Normal file
200
e2e/tests/activity-moderation.spec.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
captureBrowserFailures,
|
||||
loginWithMagicLink,
|
||||
registerAndConfirm,
|
||||
selectOptionContaining,
|
||||
} from "./helpers";
|
||||
|
||||
test("activity approval, privacy controls, reporting, and moderation work end to end", async ({
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
const organizer = await registerAndConfirm(
|
||||
browser,
|
||||
request,
|
||||
"activity-organizer@example.invalid",
|
||||
"E2E Activity Organizer",
|
||||
);
|
||||
const participant = await registerAndConfirm(
|
||||
browser,
|
||||
request,
|
||||
"activity-participant@example.invalid",
|
||||
"E2E Activity Participant",
|
||||
);
|
||||
const assertOrganizerClean = captureBrowserFailures(organizer.page);
|
||||
const assertParticipantClean = captureBrowserFailures(participant.page);
|
||||
|
||||
await participant.page.goto("/moderation");
|
||||
await expect(participant.page).toHaveURL(/\/requests$/);
|
||||
await expect(participant.page.getByText("Moderator access is required.")).toBeVisible();
|
||||
|
||||
await organizer.page.goto("/activities/new");
|
||||
await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea");
|
||||
await organizer.page.getByLabel("Preferred setting").selectOption("cafe");
|
||||
await organizer.page.getByLabel("Short title").fill("E2E public coffee meetup");
|
||||
await organizer.page
|
||||
.getByLabel("Plan and expectations")
|
||||
.fill("Meet at a public café for an hour of safe conversation.");
|
||||
|
||||
const startsAt = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString().slice(0, 16);
|
||||
const joinDeadline = new Date(Date.now() + 2 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
await organizer.page.getByLabel("Starts at (UTC)").fill(startsAt);
|
||||
await organizer.page.getByLabel("Join requests close (UTC)").fill(joinDeadline);
|
||||
await organizer.page.getByLabel("Total group capacity").fill("3");
|
||||
await organizer.page
|
||||
.getByLabel("Approximate public meeting area")
|
||||
.fill("E2E central public café");
|
||||
await organizer.page.getByLabel("Public map visibility").selectOption("approximate_public");
|
||||
await organizer.page.getByLabel("Latitude").fill("50.4501");
|
||||
await organizer.page.getByLabel("Longitude").fill("30.5234");
|
||||
await organizer.page.locator("#activity-form input[type=checkbox]").check();
|
||||
await organizer.page.getByRole("button", { name: "Publish activity" }).click();
|
||||
await expect(organizer.page).toHaveURL(/\/activities\/[0-9a-f-]+$/);
|
||||
const activityURL = organizer.page.url();
|
||||
|
||||
await participant.page.goto(activityURL);
|
||||
await expect(participant.page.getByRole("heading", { name: "Approved group chat" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await participant.page.getByRole("button", { name: "Request to join" }).click();
|
||||
await expect(participant.page.getByText("Approval pending")).toBeVisible();
|
||||
await expect(organizer.page.getByText("E2E Activity Participant")).toBeVisible();
|
||||
|
||||
const organizerControls = organizer.page
|
||||
.getByRole("heading", { name: "Organizer controls" })
|
||||
.locator("..");
|
||||
await organizerControls.getByRole("button", { name: "Approve" }).click();
|
||||
await expect(
|
||||
participant.page.getByRole("heading", { name: "Approved group chat" }),
|
||||
).toBeVisible();
|
||||
|
||||
await participant.page
|
||||
.getByPlaceholder("Message the approved group")
|
||||
.fill("I will meet you by the public entrance.");
|
||||
await participant.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await expect(organizer.page.getByText("I will meet you by the public entrance.")).toBeVisible();
|
||||
|
||||
await organizer.page
|
||||
.getByPlaceholder("Message the approved group")
|
||||
.fill("Welcome to the E2E activity group.");
|
||||
await organizer.page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
await expect(participant.page.getByText("Welcome to the E2E activity group.")).toBeVisible();
|
||||
|
||||
const organizerMessage = participant.page
|
||||
.locator("#activity-messages article")
|
||||
.filter({ hasText: "Welcome to the E2E activity group." });
|
||||
await organizerMessage.getByRole("button", { name: "Report" }).click();
|
||||
const safetyControls = participant.page
|
||||
.getByRole("heading", { name: "Safety controls" })
|
||||
.locator("..");
|
||||
await expect(
|
||||
safetyControls.getByText("The report is scoped to the selected group message."),
|
||||
).toBeVisible();
|
||||
await safetyControls.getByLabel("Reason").selectOption("harassment");
|
||||
await safetyControls
|
||||
.getByLabel("What happened?")
|
||||
.fill("E2E moderation evidence for the selected activity message.");
|
||||
await safetyControls.getByRole("button", { name: "Send report" }).click();
|
||||
await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible();
|
||||
|
||||
await participant.page.goto("/categories/proposals");
|
||||
await participant.page.getByLabel("Proposed category").fill("E2E quiet board games");
|
||||
await participant.page.getByLabel("Mode").selectOption("activity");
|
||||
await participant.page
|
||||
.getByLabel("Why is this useful?")
|
||||
.fill("A moderated public activity category for quiet board-game meetups.");
|
||||
await participant.page.getByRole("button", { name: "Publish proposal" }).click();
|
||||
await expect(
|
||||
participant.page.getByText("Proposal published for community voting."),
|
||||
).toBeVisible();
|
||||
|
||||
await participant.page.goto(activityURL);
|
||||
await participant.page.getByRole("button", { name: "Block organizer" }).click();
|
||||
await expect(participant.page).toHaveURL(/\/activities$/);
|
||||
await participant.page.goto("/profile");
|
||||
const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator("..");
|
||||
await expect(blockedUsers.getByText("E2E Activity Organizer")).toBeVisible();
|
||||
await blockedUsers.getByRole("button", { name: "Unblock" }).click();
|
||||
await expect(participant.page.getByText("User unblocked.")).toBeVisible();
|
||||
|
||||
await participant.page.getByLabel("Default location visibility").selectOption(
|
||||
"exact_for_active_match",
|
||||
);
|
||||
await participant.page
|
||||
.getByLabel("Optional external thank-you link")
|
||||
.fill("https://example.invalid/e2e-thanks");
|
||||
await participant.page.getByRole("button", { name: "Save profile" }).click();
|
||||
await expect(participant.page.getByText("Profile updated.")).toBeVisible();
|
||||
|
||||
await participant.page.getByLabel("Network").selectOption("telegram");
|
||||
await participant.page.getByLabel("Public profile URL").fill("https://t.me/e2e_participant");
|
||||
await participant.page.getByLabel("Handle (optional)").fill("@e2e_participant");
|
||||
await participant.page.getByRole("button", { name: "Add unverified link" }).click();
|
||||
await expect(participant.page.getByText("Social link added as unverified.")).toBeVisible();
|
||||
const socialSection = participant.page
|
||||
.getByRole("heading", { name: "Social links" })
|
||||
.locator("xpath=ancestor::section");
|
||||
await expect(socialSection.getByText("telegram", { exact: true })).toBeVisible();
|
||||
await socialSection.getByRole("button", { name: "Remove" }).click();
|
||||
await expect(socialSection.getByText("No social links added.")).toBeVisible();
|
||||
|
||||
await participant.page.goto("/requests/new");
|
||||
await expect(participant.page.getByLabel("Location visibility")).toHaveValue(
|
||||
"exact_for_active_match",
|
||||
);
|
||||
|
||||
const admin = await loginWithMagicLink(browser, request, "e2e-admin@example.invalid");
|
||||
const assertAdminClean = captureBrowserFailures(admin.page);
|
||||
await admin.page.goto("/moderation");
|
||||
await expect(admin.page.getByRole("heading", { name: "Moderation" })).toBeVisible();
|
||||
|
||||
const reportCard = admin.page
|
||||
.locator("article")
|
||||
.filter({ hasText: "E2E moderation evidence for the selected activity message." });
|
||||
await reportCard.getByRole("button", { name: "View scoped evidence" }).click();
|
||||
const evidence = admin.page
|
||||
.getByRole("heading", { name: "Reported evidence" })
|
||||
.locator("xpath=ancestor::section");
|
||||
await expect(evidence.getByText("Welcome to the E2E activity group.")).toBeVisible();
|
||||
await evidence.getByRole("button", { name: "Close" }).click();
|
||||
await reportCard.getByRole("combobox").selectOption("resolved");
|
||||
await reportCard.getByPlaceholder("Resolution note").fill("Reviewed in isolated browser E2E.");
|
||||
await reportCard.getByRole("button", { name: "Save" }).click();
|
||||
await expect(admin.page.getByText("Report updated.")).toBeVisible();
|
||||
|
||||
const proposalCard = admin.page
|
||||
.locator("article")
|
||||
.filter({ hasText: "E2E quiet board games" });
|
||||
await proposalCard.getByPlaceholder("Rejection note").fill("Covered by an existing category.");
|
||||
await proposalCard.getByRole("button", { name: "Reject" }).click();
|
||||
await expect(admin.page.getByText("Proposal rejected.")).toBeVisible();
|
||||
|
||||
const participantRow = admin.page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "activity-participant@example.invalid" });
|
||||
await participantRow.getByRole("combobox").first().selectOption("restricted");
|
||||
await participantRow.getByPlaceholder("Internal note").fill("E2E restriction boundary.");
|
||||
await participantRow.getByRole("button", { name: "Save" }).click();
|
||||
await expect(admin.page.getByText("User status updated.")).toBeVisible();
|
||||
|
||||
await participant.page.goto("/categories/proposals");
|
||||
await participant.page.getByLabel("Proposed category").fill("E2E restricted proposal");
|
||||
await participant.page.getByLabel("Mode").selectOption("help");
|
||||
await participant.page
|
||||
.getByLabel("Why is this useful?")
|
||||
.fill("This must be rejected because the account is restricted.");
|
||||
await participant.page.getByRole("button", { name: "Publish proposal" }).click();
|
||||
await expect(
|
||||
participant.page.getByText("Confirm your account and ensure it is active first."),
|
||||
).toBeVisible();
|
||||
|
||||
assertOrganizerClean();
|
||||
assertParticipantClean();
|
||||
assertAdminClean();
|
||||
await organizer.context.close();
|
||||
await participant.context.close();
|
||||
await admin.context.close();
|
||||
});
|
||||
|
|
@ -62,12 +62,10 @@ export function captureBrowserFailures(page: Page): () => void {
|
|||
return () => expect(failures, failures.join("\n")).toEqual([]);
|
||||
}
|
||||
|
||||
async function waitForMagicLink(request: APIRequestContext, email: string): Promise<string> {
|
||||
let messageID: string | undefined;
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
async function latestMessageID(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
): Promise<string | undefined> {
|
||||
const response = await request.get(`${mailpitURL}/api/v1/search`, {
|
||||
params: {
|
||||
query: `to:"${email}"`,
|
||||
|
|
@ -77,8 +75,21 @@ async function waitForMagicLink(request: APIRequestContext, email: string): Prom
|
|||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = (await response.json()) as { messages?: Array<{ ID?: string }> };
|
||||
messageID = body.messages?.[0]?.ID;
|
||||
return messageID;
|
||||
return body.messages?.[0]?.ID;
|
||||
}
|
||||
|
||||
async function waitForMagicLink(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
previousMessageID?: string,
|
||||
): Promise<string> {
|
||||
let messageID: string | undefined;
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
messageID = await latestMessageID(request, email);
|
||||
return messageID && messageID !== previousMessageID ? messageID : undefined;
|
||||
},
|
||||
{
|
||||
message: `waiting for the Mailpit login message for ${email}`,
|
||||
|
|
@ -138,6 +149,33 @@ export async function registerAndConfirm(
|
|||
return { context, page, email };
|
||||
}
|
||||
|
||||
export async function loginWithMagicLink(
|
||||
browser: Browser,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
): Promise<AuthenticatedBrowser> {
|
||||
const context = await newIsolatedContext(browser);
|
||||
const page = await context.newPage();
|
||||
const previousMessageID = await latestMessageID(request, email);
|
||||
|
||||
await page.goto("/users/log-in");
|
||||
const form = page.locator("#login_form_magic");
|
||||
await form.getByLabel("Email").fill(email);
|
||||
await form.getByRole("button", { name: "Log in with email" }).click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"If your email is in our system, you will receive instructions for logging in shortly.",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto(await waitForMagicLink(request, email, previousMessageID));
|
||||
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Log me in only this time" }).click();
|
||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||
|
||||
return { context, page, email };
|
||||
}
|
||||
|
||||
export async function selectOptionContaining(
|
||||
page: Page,
|
||||
label: string,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,20 @@ trap cleanup EXIT HUP INT TERM
|
|||
compose config --quiet
|
||||
compose up -d --build --wait db mailpit migrate proxy web worker
|
||||
compose build e2e
|
||||
compose run --rm --no-deps e2e \
|
||||
npx playwright test --config=playwright.bootstrap.config.ts
|
||||
|
||||
admin_email="e2e-admin@example.invalid"
|
||||
encoded_admin_email=$(printf %s "$admin_email" | base64 | tr -d '\n')
|
||||
admin_expression="case Base.decode64!(\"$encoded_admin_email\") |> WhoNeedHelp.Release.bootstrap_admin() do {:ok, admin} -> IO.inspect({:ok, admin.email}); {:error, reason} -> raise \"Admin bootstrap failed: #{inspect(reason)}\" end"
|
||||
web_container=$(compose ps -q web | head -n 1)
|
||||
|
||||
if [ -z "$web_container" ]; then
|
||||
echo "No E2E web replica was found for administrator bootstrap." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec "$web_container" /app/bin/who_need_help rpc "$admin_expression"
|
||||
compose run --rm --no-deps e2e
|
||||
|
||||
echo "Isolated browser E2E passed."
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user