test: verify full public staging flows
This commit is contained in:
parent
1c11ff5ed2
commit
710ad5e901
|
|
@ -654,6 +654,38 @@ scenario.
|
||||||
Evidence is `output/e2e/20260719202632-3770163`; its containers, networks,
|
Evidence is `output/e2e/20260719202632-3770163`; its containers, networks,
|
||||||
and database volume were removed by the run-scoped cleanup.
|
and database volume were removed by the run-scoped cleanup.
|
||||||
|
|
||||||
|
The broader public-staging replay was then expanded without reusing or deleting
|
||||||
|
unrelated application records:
|
||||||
|
|
||||||
|
- One run against `https://whoneedhelp.imalto.site` passed 3/3 Chromium
|
||||||
|
scenarios covering registration, email confirmation, password setup, email
|
||||||
|
change and re-login; medicine request, helper acceptance, private realtime
|
||||||
|
chat, browser geolocation sharing and deletion, handover confirmation and
|
||||||
|
double-blind reviews; motorcycle broken-chain roadside help with helper
|
||||||
|
withdrawal and requester cancellation; Activity creation, join approval,
|
||||||
|
approved group chat, reporting, evidence, blocking/unblocking, privacy
|
||||||
|
defaults, social-link add/remove, category proposal and moderator actions.
|
||||||
|
- The fixture tool first verified the exact database name and an unused
|
||||||
|
run-specific email prefix. Cleanup validated ownership of every related
|
||||||
|
request, assignment, activity, participant, message, report, proposal,
|
||||||
|
review, tracking, audit and push-job record before deleting it. It removed
|
||||||
|
only the six observed run users and their validated relationships. Exact
|
||||||
|
Mailpit message IDs for the run were deleted separately and their absence was
|
||||||
|
rechecked. The before/after counts across 19 application tables produced an
|
||||||
|
empty diff. Evidence is
|
||||||
|
`output/staging-full-e2e/20260719210733-461538`.
|
||||||
|
- The current isolated browser matrix passed bootstrap 1/1 and application
|
||||||
|
scenarios 30/30: 10 each in Chromium, Firefox and WebKit. It includes active
|
||||||
|
BEAM-node failure while chat and browser tracking are in use, followed by
|
||||||
|
reconnect on an available replica and continued chat/tracking operation.
|
||||||
|
Evidence is `output/e2e/20260719211815-708067`; the isolated containers,
|
||||||
|
networks and PostgreSQL volume were removed automatically.
|
||||||
|
- `./scripts/quality.sh` passed on the same source state: formatting, strict
|
||||||
|
compilation, xref, Credo, Sobelow, Dialyzer, 173 ExUnit tests, dependency
|
||||||
|
audits, Compose/Helm/observability checks and the configured image scans.
|
||||||
|
Evidence is
|
||||||
|
`output/regression/full-public-web-quality-20260719.log`.
|
||||||
|
|
||||||
## Known work before a public production launch
|
## Known work before a public production launch
|
||||||
|
|
||||||
- Replace the temporary staging origin with the production-owned domain and
|
- Replace the temporary staging origin with the production-owned domain and
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
|
clickUntilVisible,
|
||||||
|
gotoLiveView,
|
||||||
markerKinds,
|
markerKinds,
|
||||||
projectEmail,
|
projectEmail,
|
||||||
projectText,
|
projectText,
|
||||||
|
|
@ -43,7 +45,7 @@ test("active chat and browser tracking recover after the serving BEAM node resta
|
||||||
);
|
);
|
||||||
const requestTitle = projectText("Failover medicine pickup", testInfo.project.name);
|
const requestTitle = projectText("Failover medicine pickup", testInfo.project.name);
|
||||||
|
|
||||||
await requester.page.goto("/requests/new");
|
await gotoLiveView(requester.page, "/requests/new");
|
||||||
await selectOptionContaining(requester.page, "Category", "Medicine pickup");
|
await selectOptionContaining(requester.page, "Category", "Medicine pickup");
|
||||||
await requester.page.getByLabel("Medicine pickup status").selectOption("reserved");
|
await requester.page.getByLabel("Medicine pickup status").selectOption("reserved");
|
||||||
await requester.page.getByLabel("Short title").fill(requestTitle);
|
await requester.page.getByLabel("Short title").fill(requestTitle);
|
||||||
|
|
@ -70,9 +72,11 @@ test("active chat and browser tracking recover after the serving BEAM node resta
|
||||||
longitude: 30.5237,
|
longitude: 30.5237,
|
||||||
accuracy: 5,
|
accuracy: 5,
|
||||||
});
|
});
|
||||||
await helper.page.goto(requestURL);
|
await gotoLiveView(helper.page, requestURL);
|
||||||
await helper.page.getByRole("button", { name: "I can help" }).click();
|
await clickUntilVisible(
|
||||||
await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
helper.page.getByRole("button", { name: "I can help" }),
|
||||||
|
helper.page.getByRole("heading", { name: "Private match chat" }),
|
||||||
|
);
|
||||||
await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
||||||
|
|
||||||
const browserPosition = await helper.page.evaluate(
|
const browserPosition = await helper.page.evaluate(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
captureBrowserFailures,
|
captureBrowserFailures,
|
||||||
|
clickUntilVisible,
|
||||||
|
gotoLiveView,
|
||||||
loginWithMagicLink,
|
loginWithMagicLink,
|
||||||
|
loginWithPassword,
|
||||||
projectEmail,
|
projectEmail,
|
||||||
projectText,
|
projectText,
|
||||||
registerAndConfirm,
|
registerAndConfirm,
|
||||||
|
|
@ -26,26 +29,33 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
||||||
projectName,
|
projectName,
|
||||||
);
|
);
|
||||||
const proposalTitle = projectText("E2E quiet board games", projectName);
|
const proposalTitle = projectText("E2E quiet board games", projectName);
|
||||||
const organizer = await registerAndConfirm(
|
const fixturePassword = process.env.E2E_FIXTURE_PASSWORD;
|
||||||
browser,
|
const organizer =
|
||||||
request,
|
process.env.E2E_RUN_ID && fixturePassword
|
||||||
organizerEmail,
|
? await loginWithPassword(browser, organizerEmail, fixturePassword)
|
||||||
"E2E Activity Organizer",
|
: await registerAndConfirm(
|
||||||
);
|
browser,
|
||||||
const participant = await registerAndConfirm(
|
request,
|
||||||
browser,
|
organizerEmail,
|
||||||
request,
|
"E2E Activity Organizer",
|
||||||
participantEmail,
|
);
|
||||||
"E2E Activity Participant",
|
const participant =
|
||||||
);
|
process.env.E2E_RUN_ID && fixturePassword
|
||||||
|
? await loginWithPassword(browser, participantEmail, fixturePassword)
|
||||||
|
: await registerAndConfirm(
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
participantEmail,
|
||||||
|
"E2E Activity Participant",
|
||||||
|
);
|
||||||
const assertOrganizerClean = captureBrowserFailures(organizer.page);
|
const assertOrganizerClean = captureBrowserFailures(organizer.page);
|
||||||
const assertParticipantClean = captureBrowserFailures(participant.page);
|
const assertParticipantClean = captureBrowserFailures(participant.page);
|
||||||
|
|
||||||
await participant.page.goto("/moderation");
|
await gotoLiveView(participant.page, "/moderation");
|
||||||
await expect(participant.page).toHaveURL(/\/requests$/);
|
await expect(participant.page).toHaveURL(/\/requests$/);
|
||||||
await expect(participant.page.getByText("Moderator access is required.")).toBeVisible();
|
await expect(participant.page.getByText("Moderator access is required.")).toBeVisible();
|
||||||
|
|
||||||
await organizer.page.goto("/activities/new");
|
await gotoLiveView(organizer.page, "/activities/new");
|
||||||
await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea");
|
await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea");
|
||||||
await organizer.page.getByLabel("Preferred setting").selectOption("cafe");
|
await organizer.page.getByLabel("Preferred setting").selectOption("cafe");
|
||||||
await organizer.page.getByLabel("Short title").fill(activityTitle);
|
await organizer.page.getByLabel("Short title").fill(activityTitle);
|
||||||
|
|
@ -71,12 +81,14 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
||||||
await expect(organizer.page).toHaveURL(/\/activities\/[0-9a-f-]+$/);
|
await expect(organizer.page).toHaveURL(/\/activities\/[0-9a-f-]+$/);
|
||||||
const activityURL = organizer.page.url();
|
const activityURL = organizer.page.url();
|
||||||
|
|
||||||
await participant.page.goto(activityURL);
|
await gotoLiveView(participant.page, activityURL);
|
||||||
await expect(participant.page.getByRole("heading", { name: "Approved group chat" })).toHaveCount(
|
await expect(participant.page.getByRole("heading", { name: "Approved group chat" })).toHaveCount(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await participant.page.getByRole("button", { name: "Request to join" }).click();
|
await clickUntilVisible(
|
||||||
await expect(participant.page.getByText("Approval pending")).toBeVisible();
|
participant.page.getByRole("button", { name: "Request to join" }),
|
||||||
|
participant.page.getByText("Approval pending"),
|
||||||
|
);
|
||||||
await expect(organizer.page.getByText("E2E Activity Participant")).toBeVisible();
|
await expect(organizer.page.getByText("E2E Activity Participant")).toBeVisible();
|
||||||
|
|
||||||
const organizerControls = organizer.page
|
const organizerControls = organizer.page
|
||||||
|
|
@ -117,7 +129,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
||||||
await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible();
|
await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible();
|
||||||
|
|
||||||
await waitForMapReady(participant.page);
|
await waitForMapReady(participant.page);
|
||||||
await participant.page.goto("/categories/proposals");
|
await gotoLiveView(participant.page, "/categories/proposals");
|
||||||
await participant.page.getByLabel("Proposed category").fill(proposalTitle);
|
await participant.page.getByLabel("Proposed category").fill(proposalTitle);
|
||||||
await participant.page.getByLabel("Mode").selectOption("activity");
|
await participant.page.getByLabel("Mode").selectOption("activity");
|
||||||
await participant.page
|
await participant.page
|
||||||
|
|
@ -128,12 +140,12 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
||||||
participant.page.getByText("Proposal published for community voting."),
|
participant.page.getByText("Proposal published for community voting."),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await participant.page.goto(activityURL);
|
await gotoLiveView(participant.page, activityURL);
|
||||||
await waitForMapReady(participant.page);
|
await waitForMapReady(participant.page);
|
||||||
await participant.page.getByRole("button", { name: "Block organizer" }).click();
|
await participant.page.getByRole("button", { name: "Block organizer" }).click();
|
||||||
await expect(participant.page).toHaveURL(/\/activities$/);
|
await expect(participant.page).toHaveURL(/\/activities$/);
|
||||||
await waitForMapReady(participant.page);
|
await waitForMapReady(participant.page);
|
||||||
await participant.page.goto("/profile");
|
await gotoLiveView(participant.page, "/profile");
|
||||||
const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator("..");
|
const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator("..");
|
||||||
await expect(blockedUsers.getByText("E2E Activity Organizer")).toBeVisible();
|
await expect(blockedUsers.getByText("E2E Activity Organizer")).toBeVisible();
|
||||||
await blockedUsers.getByRole("button", { name: "Unblock" }).click();
|
await blockedUsers.getByRole("button", { name: "Unblock" }).click();
|
||||||
|
|
@ -160,14 +172,18 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
||||||
await socialSection.getByRole("button", { name: "Remove" }).click();
|
await socialSection.getByRole("button", { name: "Remove" }).click();
|
||||||
await expect(socialSection.getByText("No social links added.")).toBeVisible();
|
await expect(socialSection.getByText("No social links added.")).toBeVisible();
|
||||||
|
|
||||||
await participant.page.goto("/requests/new");
|
await gotoLiveView(participant.page, "/requests/new");
|
||||||
await expect(participant.page.getByLabel("Location visibility")).toHaveValue(
|
await expect(participant.page.getByLabel("Location visibility")).toHaveValue(
|
||||||
"exact_for_active_match",
|
"exact_for_active_match",
|
||||||
);
|
);
|
||||||
|
|
||||||
const admin = await loginWithMagicLink(browser, request, "e2e-admin@example.invalid");
|
const adminEmail = process.env.E2E_ADMIN_EMAIL ?? "e2e-admin@example.invalid";
|
||||||
|
const admin =
|
||||||
|
process.env.E2E_RUN_ID && fixturePassword
|
||||||
|
? await loginWithPassword(browser, adminEmail, fixturePassword)
|
||||||
|
: await loginWithMagicLink(browser, request, adminEmail);
|
||||||
const assertAdminClean = captureBrowserFailures(admin.page);
|
const assertAdminClean = captureBrowserFailures(admin.page);
|
||||||
await admin.page.goto("/moderation");
|
await gotoLiveView(admin.page, "/moderation");
|
||||||
await expect(admin.page.getByRole("heading", { name: "Moderation" })).toBeVisible();
|
await expect(admin.page.getByRole("heading", { name: "Moderation" })).toBeVisible();
|
||||||
|
|
||||||
const reportCard = admin.page
|
const reportCard = admin.page
|
||||||
|
|
@ -199,7 +215,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
||||||
await participantRow.getByRole("button", { name: "Save" }).click();
|
await participantRow.getByRole("button", { name: "Save" }).click();
|
||||||
await expect(admin.page.getByText("User status updated.")).toBeVisible();
|
await expect(admin.page.getByText("User status updated.")).toBeVisible();
|
||||||
|
|
||||||
await participant.page.goto("/categories/proposals");
|
await gotoLiveView(participant.page, "/categories/proposals");
|
||||||
await participant.page.getByLabel("Proposed category").fill("E2E restricted proposal");
|
await participant.page.getByLabel("Proposed category").fill("E2E restricted proposal");
|
||||||
await participant.page.getByLabel("Mode").selectOption("help");
|
await participant.page.getByLabel("Mode").selectOption("help");
|
||||||
await participant.page
|
await participant.page
|
||||||
|
|
|
||||||
62
e2e/tests/auth-settings.spec.ts
Normal file
62
e2e/tests/auth-settings.spec.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
captureBrowserFailures,
|
||||||
|
clickUntilVisible,
|
||||||
|
gotoWithTransientRetry,
|
||||||
|
loginWithPassword,
|
||||||
|
projectEmail,
|
||||||
|
registerAndConfirm,
|
||||||
|
waitForApplicationEmailLink,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
test("registration, email confirmation, password, and email change work end to end", async ({
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
}, testInfo) => {
|
||||||
|
const originalEmail = projectEmail("auth-user", testInfo.project.name);
|
||||||
|
const changedEmail = projectEmail("auth-user-changed", testInfo.project.name);
|
||||||
|
const password = "E2E public password 2026!";
|
||||||
|
const user = await registerAndConfirm(
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
originalEmail,
|
||||||
|
"E2E Auth User",
|
||||||
|
);
|
||||||
|
const assertBrowserClean = captureBrowserFailures(user.page);
|
||||||
|
|
||||||
|
await user.page.goto("/users/settings");
|
||||||
|
const passwordForm = user.page.locator("#update_password");
|
||||||
|
await passwordForm.getByLabel("New password", { exact: true }).fill(password);
|
||||||
|
await passwordForm.getByLabel("Confirm new password", { exact: true }).fill(password);
|
||||||
|
await clickUntilVisible(
|
||||||
|
passwordForm.getByRole("button", { name: "Save Password" }),
|
||||||
|
user.page.getByText("Password updated successfully."),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailForm = user.page.locator("#update_email");
|
||||||
|
await emailForm.getByLabel("Email").fill(changedEmail);
|
||||||
|
await clickUntilVisible(
|
||||||
|
emailForm.getByRole("button", { name: "Change Email" }),
|
||||||
|
user.page.getByText("A link to confirm your email change has been sent"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await gotoWithTransientRetry(
|
||||||
|
user.page,
|
||||||
|
await waitForApplicationEmailLink(
|
||||||
|
request,
|
||||||
|
changedEmail,
|
||||||
|
"/users/settings/confirm-email/",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(user.page.getByText("Email changed successfully.")).toBeVisible();
|
||||||
|
|
||||||
|
await user.context.close();
|
||||||
|
|
||||||
|
const passwordLogin = await loginWithPassword(browser, changedEmail, password);
|
||||||
|
const assertPasswordLoginClean = captureBrowserFailures(passwordLogin.page);
|
||||||
|
await expect(passwordLogin.page.getByText(changedEmail, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
assertBrowserClean();
|
||||||
|
assertPasswordLoginClean();
|
||||||
|
await passwordLogin.context.close();
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,9 @@ import {
|
||||||
Browser,
|
Browser,
|
||||||
BrowserContext,
|
BrowserContext,
|
||||||
expect,
|
expect,
|
||||||
|
Locator,
|
||||||
Page,
|
Page,
|
||||||
|
Request,
|
||||||
} from "@playwright/test";
|
} from "@playwright/test";
|
||||||
|
|
||||||
const mailpitURL = process.env.MAILPIT_URL;
|
const mailpitURL = process.env.MAILPIT_URL;
|
||||||
|
|
@ -38,12 +40,84 @@ export async function newIsolatedContext(browser: Browser): Promise<BrowserConte
|
||||||
return browser.newContext({ serviceWorkers: "block" });
|
return browser.newContext({ serviceWorkers: "block" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function gotoWithTransientRetry(
|
||||||
|
page: Page,
|
||||||
|
url: string,
|
||||||
|
attempts = 3,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await page.goto(url);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const transient = isTransientNetworkError(message);
|
||||||
|
|
||||||
|
if (!transient || attempt === attempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTransientNetworkError(message: string): boolean {
|
||||||
|
return [
|
||||||
|
"ERR_NETWORK_CHANGED",
|
||||||
|
"ERR_CONNECTION_RESET",
|
||||||
|
"ERR_CONNECTION_CLOSED",
|
||||||
|
"NS_ERROR_NET_RESET",
|
||||||
|
].some((value) => message.includes(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickUntilVisible(
|
||||||
|
trigger: Locator,
|
||||||
|
expected: Locator,
|
||||||
|
attempts = 3,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
if (await expected.isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(expected).toBeVisible({ timeout: 5_000 });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (attempt === attempts || !(await trigger.isVisible())) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trigger.page().waitForTimeout(500 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForLiveViewConnected(page: Page): Promise<void> {
|
export async function waitForLiveViewConnected(page: Page): Promise<void> {
|
||||||
await expect(page.locator("[data-phx-main].phx-connected")).toHaveCount(1, {
|
await expect(page.locator("[data-phx-main].phx-connected")).toHaveCount(1, {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function gotoLiveView(page: Page, url: string): Promise<void> {
|
||||||
|
await gotoWithTransientRetry(page, url);
|
||||||
|
await waitForLiveViewConnected(page);
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForMapReady(page: Page): Promise<void> {
|
export async function waitForMapReady(page: Page): Promise<void> {
|
||||||
const map = page.locator(".aid-map");
|
const map = page.locator(".aid-map");
|
||||||
|
|
||||||
|
|
@ -95,11 +169,17 @@ export function captureBrowserFailures(page: Page): () => void {
|
||||||
const rasterTile =
|
const rasterTile =
|
||||||
request.url().includes("/__e2e__/map-tile.png") ||
|
request.url().includes("/__e2e__/map-tile.png") ||
|
||||||
/\/\d+\/\d+\/\d+\.png(?:\?|$)/.test(request.url());
|
/\/\d+\/\d+\/\d+\.png(?:\?|$)/.test(request.url());
|
||||||
|
const replacedDocumentAsset =
|
||||||
|
cancelled &&
|
||||||
|
new URL(request.url()).origin === new URL(baseURL).origin &&
|
||||||
|
["script", "stylesheet"].includes(request.resourceType());
|
||||||
|
|
||||||
// MapLibre cancels in-flight raster tiles when a LiveView navigation
|
// MapLibre cancels in-flight raster tiles when a LiveView navigation
|
||||||
// destroys or re-centres a map. Actual tile/network failures retain their
|
// destroys or re-centres a map. Chromium also aborts the old document's
|
||||||
// engine error and remain test failures.
|
// static assets when a bounded navigation retry replaces that document.
|
||||||
if (rasterTile && cancelled) {
|
// Actual tile/network failures retain their engine error and remain test
|
||||||
|
// failures.
|
||||||
|
if ((rasterTile && cancelled) || replacedDocumentAsset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,7 +191,7 @@ export function captureBrowserFailures(page: Page): () => void {
|
||||||
return () => expect(failures, failures.join("\n")).toEqual([]);
|
return () => expect(failures, failures.join("\n")).toEqual([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function latestMessageID(
|
export async function latestMessageID(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
|
|
@ -127,9 +207,10 @@ async function latestMessageID(
|
||||||
return body.messages?.[0]?.ID;
|
return body.messages?.[0]?.ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForMagicLink(
|
export async function waitForApplicationEmailLink(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
email: string,
|
email: string,
|
||||||
|
pathPrefix: string,
|
||||||
previousMessageID?: string,
|
previousMessageID?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let messageID: string | undefined;
|
let messageID: string | undefined;
|
||||||
|
|
@ -155,13 +236,22 @@ async function waitForMagicLink(
|
||||||
HTML?: string;
|
HTML?: string;
|
||||||
};
|
};
|
||||||
const content = `${message.Text ?? ""}\n${message.HTML ?? ""}`;
|
const content = `${message.Text ?? ""}\n${message.HTML ?? ""}`;
|
||||||
const match = content.match(/https?:\/\/[^\s"'<>]+\/users\/log-in\/[^\s"'<>]+/);
|
const candidates = content.match(/https?:\/\/[^\s"'<>]+/g) ?? [];
|
||||||
|
const match = candidates
|
||||||
|
.map((candidate) => candidate.replaceAll("&", "&"))
|
||||||
|
.find((candidate) => {
|
||||||
|
try {
|
||||||
|
return new URL(candidate).pathname.startsWith(pathPrefix);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`No magic login link found for ${email}`);
|
throw new Error(`No application link starting with ${pathPrefix} found for ${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = new URL(match[0].replaceAll("&", "&"));
|
const link = new URL(match);
|
||||||
const applicationOrigin = new URL(baseURL);
|
const applicationOrigin = new URL(baseURL);
|
||||||
link.protocol = applicationOrigin.protocol;
|
link.protocol = applicationOrigin.protocol;
|
||||||
link.host = applicationOrigin.host;
|
link.host = applicationOrigin.host;
|
||||||
|
|
@ -169,6 +259,19 @@ async function waitForMagicLink(
|
||||||
return link.toString();
|
return link.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForMagicLink(
|
||||||
|
request: APIRequestContext,
|
||||||
|
email: string,
|
||||||
|
previousMessageID?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return waitForApplicationEmailLink(
|
||||||
|
request,
|
||||||
|
email,
|
||||||
|
"/users/log-in/",
|
||||||
|
previousMessageID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerAndConfirm(
|
export async function registerAndConfirm(
|
||||||
browser: Browser,
|
browser: Browser,
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
|
|
@ -178,7 +281,7 @@ export async function registerAndConfirm(
|
||||||
const context = await newIsolatedContext(browser);
|
const context = await newIsolatedContext(browser);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
await page.goto("/users/register");
|
await gotoWithTransientRetry(page, "/users/register");
|
||||||
await page.getByLabel("Email").fill(email);
|
await page.getByLabel("Email").fill(email);
|
||||||
await page.getByLabel("Display name").fill(displayName);
|
await page.getByLabel("Display name").fill(displayName);
|
||||||
await page
|
await page
|
||||||
|
|
@ -188,7 +291,7 @@ export async function registerAndConfirm(
|
||||||
await expect(page).toHaveURL(/\/users\/log-in$/);
|
await expect(page).toHaveURL(/\/users\/log-in$/);
|
||||||
await expect(page.getByText(`An email was sent to ${email}`)).toBeVisible();
|
await expect(page.getByText(`An email was sent to ${email}`)).toBeVisible();
|
||||||
|
|
||||||
await page.goto(await waitForMagicLink(request, email));
|
await gotoWithTransientRetry(page, await waitForMagicLink(request, email));
|
||||||
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
|
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "Confirm and log in only this time" })
|
.getByRole("button", { name: "Confirm and log in only this time" })
|
||||||
|
|
@ -207,7 +310,7 @@ export async function loginWithMagicLink(
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const previousMessageID = await latestMessageID(request, email);
|
const previousMessageID = await latestMessageID(request, email);
|
||||||
|
|
||||||
await page.goto("/users/log-in");
|
await gotoWithTransientRetry(page, "/users/log-in");
|
||||||
const form = page.locator("#login_form_magic");
|
const form = page.locator("#login_form_magic");
|
||||||
await form.getByLabel("Email").fill(email);
|
await form.getByLabel("Email").fill(email);
|
||||||
await form.getByRole("button", { name: "Log in with email" }).click();
|
await form.getByRole("button", { name: "Log in with email" }).click();
|
||||||
|
|
@ -217,7 +320,10 @@ export async function loginWithMagicLink(
|
||||||
),
|
),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await page.goto(await waitForMagicLink(request, email, previousMessageID));
|
await gotoWithTransientRetry(
|
||||||
|
page,
|
||||||
|
await waitForMagicLink(request, email, previousMessageID),
|
||||||
|
);
|
||||||
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
|
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Log me in only this time" }).click();
|
await page.getByRole("button", { name: "Log me in only this time" }).click();
|
||||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||||
|
|
@ -233,14 +339,49 @@ export async function loginWithPassword(
|
||||||
const context = await newIsolatedContext(browser);
|
const context = await newIsolatedContext(browser);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
await page.goto("/users/log-in");
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
const form = page.locator("#login_form_password");
|
await gotoWithTransientRetry(page, "/users/log-in");
|
||||||
await form.getByLabel("Email").fill(email);
|
const form = page.locator("#login_form_password");
|
||||||
await form.getByLabel("Password").fill(password);
|
await form.getByLabel("Email").fill(email);
|
||||||
await form.getByRole("button", { name: "Log in only this time" }).click();
|
await form.getByLabel("Password").fill(password);
|
||||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
await expect(form.getByLabel("Password")).toHaveValue(password);
|
||||||
|
|
||||||
return { context, page, email };
|
let navigationFailure = "";
|
||||||
|
const recordNavigationFailure = (request: Request): void => {
|
||||||
|
if (
|
||||||
|
request.isNavigationRequest() &&
|
||||||
|
request.method() === "POST" &&
|
||||||
|
new URL(request.url()).pathname === "/users/log-in"
|
||||||
|
) {
|
||||||
|
navigationFailure = request.failure()?.errorText ?? "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
page.on("requestfailed", recordNavigationFailure);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await form.getByRole("button", { name: "Log in only this time" }).click();
|
||||||
|
await expect(page).not.toHaveURL(/\/users\/log-in$/, { timeout: 10_000 });
|
||||||
|
return { context, page, email };
|
||||||
|
} catch (error) {
|
||||||
|
if (await page.getByText("Invalid email or password").isVisible()) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
attempt === 3 ||
|
||||||
|
!isTransientNetworkError(navigationFailure)
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500 * attempt);
|
||||||
|
} finally {
|
||||||
|
page.off("requestfailed", recordNavigationFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Password login exhausted its bounded retry loop");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function selectOptionContaining(
|
export async function selectOptionContaining(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { newIsolatedContext, projectEmail, registerAndConfirm } from "./helpers";
|
import {
|
||||||
|
gotoLiveView,
|
||||||
|
newIsolatedContext,
|
||||||
|
projectEmail,
|
||||||
|
registerAndConfirm,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
test("public language selection persists across pages", async ({ browser }) => {
|
test("public language selection persists across pages", async ({ browser }) => {
|
||||||
const context = await newIsolatedContext(browser);
|
const context = await newIsolatedContext(browser);
|
||||||
|
|
@ -31,7 +36,7 @@ test("authenticated LiveView uses the selected browser language", async ({
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.page.goto("/?locale=uk");
|
await user.page.goto("/?locale=uk");
|
||||||
await user.page.goto("/requests");
|
await gotoLiveView(user.page, "/requests");
|
||||||
|
|
||||||
await expect(user.page.locator("html")).toHaveAttribute("lang", "uk");
|
await expect(user.page.locator("html")).toHaveAttribute("lang", "uk");
|
||||||
await expect(user.page.getByRole("heading", { name: "Хто потребує допомоги?" })).toBeVisible();
|
await expect(user.page.getByRole("heading", { name: "Хто потребує допомоги?" })).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
captureBrowserFailures,
|
captureBrowserFailures,
|
||||||
|
clickUntilVisible,
|
||||||
|
gotoLiveView,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
markerKinds,
|
markerKinds,
|
||||||
projectEmail,
|
projectEmail,
|
||||||
projectText,
|
projectText,
|
||||||
registerAndConfirm,
|
registerAndConfirm,
|
||||||
selectOptionContaining,
|
selectOptionContaining,
|
||||||
waitForLiveViewConnected,
|
|
||||||
waitForMapReady,
|
waitForMapReady,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
|
||||||
const assertRequesterClean = captureBrowserFailures(requester.page);
|
const assertRequesterClean = captureBrowserFailures(requester.page);
|
||||||
const assertHelperClean = captureBrowserFailures(helper.page);
|
const assertHelperClean = captureBrowserFailures(helper.page);
|
||||||
|
|
||||||
await requester.page.goto("/requests/new");
|
await gotoLiveView(requester.page, "/requests/new");
|
||||||
await selectOptionContaining(requester.page, "Category", "Medicine pickup");
|
await selectOptionContaining(requester.page, "Category", "Medicine pickup");
|
||||||
await requester.page.getByLabel("Medicine pickup status").selectOption("reserved");
|
await requester.page.getByLabel("Medicine pickup status").selectOption("reserved");
|
||||||
await requester.page.getByLabel("Short title").fill(requestTitle);
|
await requester.page.getByLabel("Short title").fill(requestTitle);
|
||||||
|
|
@ -56,8 +57,7 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
|
||||||
const requestURL = requester.page.url();
|
const requestURL = requester.page.url();
|
||||||
await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
|
await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
|
||||||
|
|
||||||
await helper.page.goto(requestURL);
|
await gotoLiveView(helper.page, requestURL);
|
||||||
await waitForLiveViewConnected(helper.page);
|
|
||||||
await helper.page.getByRole("button", { name: "I can help" }).click();
|
await helper.page.getByRole("button", { name: "I can help" }).click();
|
||||||
await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
||||||
await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
||||||
|
|
@ -112,11 +112,10 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await waitForMapReady(requester.page);
|
await waitForMapReady(requester.page);
|
||||||
await requester.page.goto("/profile");
|
await gotoLiveView(requester.page, "/profile");
|
||||||
await expect(requester.page.getByText("None revealed yet.")).toBeVisible();
|
await expect(requester.page.getByText("None revealed yet.")).toBeVisible();
|
||||||
await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0);
|
await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0);
|
||||||
await requester.page.goto(requestURL);
|
await gotoLiveView(requester.page, requestURL);
|
||||||
await waitForLiveViewConnected(requester.page);
|
|
||||||
|
|
||||||
await requester.page.getByLabel("Rating").selectOption("5");
|
await requester.page.getByLabel("Rating").selectOption("5");
|
||||||
await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer.");
|
await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer.");
|
||||||
|
|
@ -126,12 +125,54 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await waitForMapReady(requester.page);
|
await waitForMapReady(requester.page);
|
||||||
await requester.page.goto("/profile");
|
await gotoLiveView(requester.page, "/profile");
|
||||||
await expect(requester.page.getByText("Clear and safe coordination.")).toBeVisible();
|
await expect(requester.page.getByText("Clear and safe coordination.")).toBeVisible();
|
||||||
await waitForMapReady(helper.page);
|
await waitForMapReady(helper.page);
|
||||||
await helper.page.goto("/profile");
|
await gotoLiveView(helper.page, "/profile");
|
||||||
await expect(helper.page.getByText("Reliable volunteer.")).toBeVisible();
|
await expect(helper.page.getByText("Reliable volunteer.")).toBeVisible();
|
||||||
|
|
||||||
|
const roadsideTitle = projectText(
|
||||||
|
runID ? `Staging E2E motorcycle chain ${runID}` : "E2E motorcycle chain",
|
||||||
|
testInfo.project.name,
|
||||||
|
);
|
||||||
|
await gotoLiveView(requester.page, "/requests/new");
|
||||||
|
await selectOptionContaining(
|
||||||
|
requester.page,
|
||||||
|
"Category",
|
||||||
|
"Motorcycle roadside help",
|
||||||
|
);
|
||||||
|
await requester.page.getByLabel("Motorcycle problem").selectOption("broken_chain");
|
||||||
|
await requester.page
|
||||||
|
.getByLabel("Motorcycle is away from active traffic")
|
||||||
|
.selectOption("true");
|
||||||
|
await requester.page.getByLabel("Short title").fill(roadsideTitle);
|
||||||
|
await requester.page
|
||||||
|
.getByLabel("What help do you need?")
|
||||||
|
.fill("The motorcycle is secured and needs help with a broken chain.");
|
||||||
|
await requester.page.getByLabel("Urgency").selectOption("today");
|
||||||
|
await requester.page
|
||||||
|
.getByLabel("Request expires (UTC)")
|
||||||
|
.fill(new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString().slice(0, 16));
|
||||||
|
await requester.page.getByLabel("Approximate area").fill("E2E secured roadside area");
|
||||||
|
await requester.page.getByLabel("Location visibility").selectOption("hidden");
|
||||||
|
await requester.page.getByLabel("Latitude").fill("50.4501");
|
||||||
|
await requester.page.getByLabel("Longitude").fill("30.5234");
|
||||||
|
await requester.page.locator("#request-form input[type=checkbox]").last().check();
|
||||||
|
await requester.page.getByRole("button", { name: "Publish request" }).click();
|
||||||
|
await expect(requester.page.getByRole("heading", { name: roadsideTitle })).toBeVisible();
|
||||||
|
|
||||||
|
const roadsideURL = requester.page.url();
|
||||||
|
await gotoLiveView(helper.page, roadsideURL);
|
||||||
|
await clickUntilVisible(
|
||||||
|
helper.page.getByRole("button", { name: "I can help" }),
|
||||||
|
helper.page.getByRole("button", { name: "Withdraw from this request" }),
|
||||||
|
);
|
||||||
|
await clickUntilVisible(
|
||||||
|
helper.page.getByRole("button", { name: "Withdraw from this request" }),
|
||||||
|
helper.page.getByText("You withdrew from this request."),
|
||||||
|
);
|
||||||
|
await expect(requester.page.getByText("Cancelled", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
assertRequesterClean();
|
assertRequesterClean();
|
||||||
assertHelperClean();
|
assertHelperClean();
|
||||||
await requester.context.close();
|
await requester.context.close();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { projectEmail, registerAndConfirm } from "./helpers";
|
import { gotoLiveView, projectEmail, registerAndConfirm } from "./helpers";
|
||||||
|
|
||||||
test("a disconnected LiveView announces recovery and clears it after reconnect", async ({
|
test("a disconnected LiveView announces recovery and clears it after reconnect", async ({
|
||||||
browser,
|
browser,
|
||||||
|
|
@ -12,7 +12,7 @@ test("a disconnected LiveView announces recovery and clears it after reconnect",
|
||||||
"E2E Resilience",
|
"E2E Resilience",
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.page.goto("/requests");
|
await gotoLiveView(user.page, "/requests");
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
user.page.evaluate(() => {
|
user.page.evaluate(() => {
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,7 @@ defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
unexpected_proposal? =
|
unexpected_proposal? =
|
||||||
Repo.exists?(from(proposal in CategoryProposal, where: proposal.proposer_id in ^user_ids))
|
Repo.exists?(from(proposal in CategoryProposal, where: proposal.proposer_id in ^user_ids))
|
||||||
|
|
||||||
unless length(request_ids) <= 1 and length(assignment_ids) <= 1 and
|
unless length(request_ids) <= 2 and length(assignment_ids) <= 2 and
|
||||||
not unexpected_request? and not unexpected_assignment? and
|
not unexpected_request? and not unexpected_assignment? and
|
||||||
not unexpected_activity? and not unexpected_proposal? do
|
not unexpected_activity? and not unexpected_proposal? do
|
||||||
Mix.raise("staging E2E users own records outside the exact medicine-flow scope")
|
Mix.raise("staging E2E users own records outside the exact medicine-flow scope")
|
||||||
|
|
|
||||||
479
lib/mix/tasks/wnh.staging_full_e2e.ex
Normal file
479
lib/mix/tasks/wnh.staging_full_e2e.ex
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
defmodule Mix.Tasks.Wnh.StagingFullE2e do
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Oban.Job
|
||||||
|
alias WhoNeedHelp.Accounts.{SocialIdentity, User}
|
||||||
|
alias WhoNeedHelp.Activities.{Activity, Participant}
|
||||||
|
alias WhoNeedHelp.Activities.Message, as: ActivityMessage
|
||||||
|
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
|
||||||
|
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
||||||
|
alias WhoNeedHelp.Messaging.Message
|
||||||
|
alias WhoNeedHelp.Repo
|
||||||
|
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Trust.{
|
||||||
|
AbuseSignal,
|
||||||
|
AuditEvent,
|
||||||
|
Block,
|
||||||
|
Report,
|
||||||
|
Review
|
||||||
|
}
|
||||||
|
|
||||||
|
@shortdoc "Prepares or removes the exact full public-staging browser fixture"
|
||||||
|
@confirmation "public-staging-full-e2e"
|
||||||
|
@precreated_roles ~w(requester helper activity-organizer activity-participant admin)
|
||||||
|
@registered_roles ~w(auth-user auth-user-changed)
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run([action]) when action in ["prepare", "cleanup"] do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
context = verified_context()
|
||||||
|
|
||||||
|
case action do
|
||||||
|
"prepare" -> prepare(context)
|
||||||
|
"cleanup" -> cleanup(context)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(_args) do
|
||||||
|
Mix.raise("usage: mix wnh.staging_full_e2e prepare|cleanup")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verified_context do
|
||||||
|
run_id = required_env!("WNH_STAGING_E2E_RUN_ID")
|
||||||
|
expected_database = required_env!("WNH_STAGING_E2E_EXPECTED_DATABASE")
|
||||||
|
manifest_path = required_env!("WNH_STAGING_E2E_MANIFEST_PATH") |> Path.expand()
|
||||||
|
|
||||||
|
unless Regex.match?(~r/^[a-z0-9-]+$/, run_id) do
|
||||||
|
Mix.raise("WNH_STAGING_E2E_RUN_ID may contain only lowercase letters, numbers, and dash")
|
||||||
|
end
|
||||||
|
|
||||||
|
unless System.get_env("WNH_STAGING_E2E_CONFIRM") == @confirmation do
|
||||||
|
Mix.raise("WNH_STAGING_E2E_CONFIRM must equal #{@confirmation}")
|
||||||
|
end
|
||||||
|
|
||||||
|
unless String.starts_with?(manifest_path, "/output/") do
|
||||||
|
Mix.raise("WNH_STAGING_E2E_MANIFEST_PATH must resolve below /output")
|
||||||
|
end
|
||||||
|
|
||||||
|
%Postgrex.Result{rows: [[actual_database]]} =
|
||||||
|
Repo.query!("SELECT current_database()", [], log: false)
|
||||||
|
|
||||||
|
unless actual_database == expected_database do
|
||||||
|
Mix.raise(
|
||||||
|
"refusing staging E2E mutation: expected database #{inspect(expected_database)}, " <>
|
||||||
|
"observed #{inspect(actual_database)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
run_id: run_id,
|
||||||
|
database: actual_database,
|
||||||
|
manifest_path: manifest_path,
|
||||||
|
emails: emails(run_id)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare(context) do
|
||||||
|
password = required_env!("WNH_STAGING_E2E_PASSWORD")
|
||||||
|
|
||||||
|
unless byte_size(password) in 12..72 do
|
||||||
|
Mix.raise("WNH_STAGING_E2E_PASSWORD must contain between 12 and 72 bytes")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_prefix_unused!(context)
|
||||||
|
|
||||||
|
password_hash = Bcrypt.hash_pwd_salt(password)
|
||||||
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
|
{:ok, users} =
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
Map.new(@precreated_roles, fn role ->
|
||||||
|
user_role = if role == "admin", do: :admin, else: :user
|
||||||
|
|
||||||
|
user =
|
||||||
|
insert_user!(
|
||||||
|
context.emails[role],
|
||||||
|
display_name(role),
|
||||||
|
password_hash,
|
||||||
|
user_role,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
|
||||||
|
{role, %{"id" => user.id, "email" => user.email}}
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
manifest = %{
|
||||||
|
"schema_version" => 1,
|
||||||
|
"run_id" => context.run_id,
|
||||||
|
"database" => context.database,
|
||||||
|
"precreated_users" => users,
|
||||||
|
"allowed_emails" => context.emails |> Map.values() |> Enum.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
context.manifest_path |> Path.dirname() |> File.mkdir_p!()
|
||||||
|
File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
|
||||||
|
File.chmod!(context.manifest_path, 0o600)
|
||||||
|
|
||||||
|
Mix.shell().info("prepared exact full staging E2E users for #{context.run_id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cleanup(context) do
|
||||||
|
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
|
||||||
|
validate_manifest!(context, manifest)
|
||||||
|
assert_only_allowed_prefix_users!(context)
|
||||||
|
|
||||||
|
users =
|
||||||
|
User
|
||||||
|
|> where([user], user.email in ^Map.values(context.emails))
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
validate_precreated_users!(manifest, users)
|
||||||
|
|
||||||
|
user_ids = Enum.map(users, & &1.id)
|
||||||
|
request_ids = ids(HelpRequest, :requester_id, user_ids)
|
||||||
|
|
||||||
|
assignment_ids =
|
||||||
|
Assignment
|
||||||
|
|> where(
|
||||||
|
[assignment],
|
||||||
|
assignment.request_id in ^request_ids or assignment.helper_id in ^user_ids
|
||||||
|
)
|
||||||
|
|> select([assignment], assignment.id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
validate_assignments!(assignment_ids, request_ids, user_ids)
|
||||||
|
|
||||||
|
message_ids = ids(Message, :assignment_id, assignment_ids)
|
||||||
|
tracking_session_ids = ids(TrackingSession, :assignment_id, assignment_ids)
|
||||||
|
|
||||||
|
activity_ids = ids(Activity, :creator_id, user_ids)
|
||||||
|
validate_activity_membership!(activity_ids, user_ids)
|
||||||
|
activity_participant_ids = ids(Participant, :activity_id, activity_ids)
|
||||||
|
activity_message_ids = ids(ActivityMessage, :activity_id, activity_ids)
|
||||||
|
|
||||||
|
proposal_ids = ids(CategoryProposal, :proposer_id, user_ids)
|
||||||
|
validate_category_votes!(proposal_ids, user_ids)
|
||||||
|
|
||||||
|
report_ids =
|
||||||
|
Report
|
||||||
|
|> where(
|
||||||
|
[report],
|
||||||
|
report.reporter_id in ^user_ids or report.request_id in ^request_ids or
|
||||||
|
report.assignment_id in ^assignment_ids or report.message_id in ^message_ids or
|
||||||
|
report.activity_id in ^activity_ids or
|
||||||
|
report.activity_message_id in ^activity_message_ids
|
||||||
|
)
|
||||||
|
|> select([report], report.id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
validate_reviewed_records!(user_ids, report_ids, proposal_ids)
|
||||||
|
|
||||||
|
abuse_signal_ids =
|
||||||
|
AbuseSignal
|
||||||
|
|> where(
|
||||||
|
[signal],
|
||||||
|
signal.subject_id in ^user_ids or signal.assignment_id in ^assignment_ids
|
||||||
|
)
|
||||||
|
|> select([signal], signal.id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
validate_audits(%{
|
||||||
|
users: user_ids,
|
||||||
|
requests: request_ids,
|
||||||
|
assignments: assignment_ids,
|
||||||
|
activities: activity_ids,
|
||||||
|
activity_participants: activity_participant_ids,
|
||||||
|
proposals: proposal_ids,
|
||||||
|
reports: report_ids,
|
||||||
|
signals: abuse_signal_ids
|
||||||
|
})
|
||||||
|
|
||||||
|
job_ids = fixture_push_job_ids(user_ids)
|
||||||
|
|
||||||
|
{:ok, deleted} =
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
%{
|
||||||
|
push_jobs: delete_ids(Job, job_ids),
|
||||||
|
audit_events:
|
||||||
|
AuditEvent
|
||||||
|
|> where([event], event.actor_id in ^user_ids)
|
||||||
|
|> delete_count(),
|
||||||
|
reports: delete_ids(Report, report_ids),
|
||||||
|
activity_messages: delete_ids(ActivityMessage, activity_message_ids),
|
||||||
|
activity_participants: delete_ids(Participant, activity_participant_ids),
|
||||||
|
activities: delete_ids(Activity, activity_ids),
|
||||||
|
tracking_positions:
|
||||||
|
Position
|
||||||
|
|> where([position], position.tracking_session_id in ^tracking_session_ids)
|
||||||
|
|> delete_count(),
|
||||||
|
tracking_sessions: delete_ids(TrackingSession, tracking_session_ids),
|
||||||
|
messages: delete_ids(Message, message_ids),
|
||||||
|
abuse_signals: delete_ids(AbuseSignal, abuse_signal_ids),
|
||||||
|
reviews:
|
||||||
|
Review
|
||||||
|
|> where(
|
||||||
|
[review],
|
||||||
|
review.assignment_id in ^assignment_ids or review.reviewer_id in ^user_ids or
|
||||||
|
review.reviewee_id in ^user_ids
|
||||||
|
)
|
||||||
|
|> delete_count(),
|
||||||
|
assignments: delete_ids(Assignment, assignment_ids),
|
||||||
|
requests: delete_ids(HelpRequest, request_ids),
|
||||||
|
category_votes:
|
||||||
|
CategoryVote
|
||||||
|
|> where(
|
||||||
|
[vote],
|
||||||
|
vote.proposal_id in ^proposal_ids or vote.user_id in ^user_ids
|
||||||
|
)
|
||||||
|
|> delete_count(),
|
||||||
|
category_proposals: delete_ids(CategoryProposal, proposal_ids),
|
||||||
|
blocks:
|
||||||
|
Block
|
||||||
|
|> where([block], block.blocker_id in ^user_ids or block.blocked_id in ^user_ids)
|
||||||
|
|> delete_count(),
|
||||||
|
social_identities:
|
||||||
|
SocialIdentity
|
||||||
|
|> where([identity], identity.user_id in ^user_ids)
|
||||||
|
|> delete_count(),
|
||||||
|
users:
|
||||||
|
User
|
||||||
|
|> where([user], user.id in ^user_ids)
|
||||||
|
|> delete_count()
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
unless deleted.users == length(users) and deleted.users >= length(@precreated_roles) do
|
||||||
|
Mix.raise(
|
||||||
|
"cleanup removed #{deleted.users} users, expected exactly the #{length(users)} observed fixtures"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_prefix_unused!(context)
|
||||||
|
Mix.shell().info("removed exact full staging E2E fixture: #{inspect(deleted)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_manifest!(context, manifest) do
|
||||||
|
expected_emails = context.emails |> Map.values() |> Enum.sort()
|
||||||
|
precreated = manifest["precreated_users"]
|
||||||
|
|
||||||
|
valid_precreated? =
|
||||||
|
is_map(precreated) and
|
||||||
|
Enum.all?(@precreated_roles, fn role ->
|
||||||
|
case precreated[role] do
|
||||||
|
%{"id" => id, "email" => email} ->
|
||||||
|
uuid?(id) and email == context.emails[role]
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
|
||||||
|
manifest["database"] == context.database and
|
||||||
|
manifest["allowed_emails"] == expected_emails and valid_precreated? do
|
||||||
|
Mix.raise("full staging E2E manifest does not match the requested run and database")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_precreated_users!(manifest, users) do
|
||||||
|
observed = Map.new(users, &{&1.email, &1.id})
|
||||||
|
|
||||||
|
unless Enum.all?(@precreated_roles, fn role ->
|
||||||
|
expected = manifest["precreated_users"][role]
|
||||||
|
observed[expected["email"]] == expected["id"]
|
||||||
|
end) do
|
||||||
|
Mix.raise("full staging E2E precreated user ownership does not match the manifest")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_assignments!(assignment_ids, request_ids, user_ids) do
|
||||||
|
unexpected? =
|
||||||
|
Repo.exists?(
|
||||||
|
from assignment in Assignment,
|
||||||
|
where:
|
||||||
|
assignment.id in ^assignment_ids and
|
||||||
|
(assignment.request_id not in ^request_ids or assignment.helper_id not in ^user_ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
if unexpected?,
|
||||||
|
do: Mix.raise("a full staging fixture assignment crosses into non-fixture data")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_activity_membership!(activity_ids, user_ids) do
|
||||||
|
unexpected_participant? =
|
||||||
|
Repo.exists?(
|
||||||
|
from participant in Participant,
|
||||||
|
where: participant.user_id in ^user_ids and participant.activity_id not in ^activity_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
unexpected_message? =
|
||||||
|
Repo.exists?(
|
||||||
|
from message in ActivityMessage,
|
||||||
|
where: message.sender_id in ^user_ids and message.activity_id not in ^activity_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
if unexpected_participant? or unexpected_message? do
|
||||||
|
Mix.raise("a full staging fixture user is linked to a non-fixture activity")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_category_votes!(proposal_ids, user_ids) do
|
||||||
|
unexpected? =
|
||||||
|
Repo.exists?(
|
||||||
|
from vote in CategoryVote,
|
||||||
|
where: vote.user_id in ^user_ids and vote.proposal_id not in ^proposal_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
if unexpected?, do: Mix.raise("a full staging fixture vote targets a non-fixture proposal")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_reviewed_records!(user_ids, report_ids, proposal_ids) do
|
||||||
|
unexpected_report? =
|
||||||
|
Repo.exists?(
|
||||||
|
from report in Report,
|
||||||
|
where: report.reviewed_by_id in ^user_ids and report.id not in ^report_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
unexpected_proposal? =
|
||||||
|
Repo.exists?(
|
||||||
|
from proposal in CategoryProposal,
|
||||||
|
where: proposal.reviewed_by_id in ^user_ids and proposal.id not in ^proposal_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
unexpected_signal? =
|
||||||
|
Repo.exists?(
|
||||||
|
from signal in AbuseSignal,
|
||||||
|
where: signal.reviewed_by_id in ^user_ids and signal.subject_id not in ^user_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
if unexpected_report? or unexpected_proposal? or unexpected_signal? do
|
||||||
|
Mix.raise("the run-scoped moderator changed a non-fixture record")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_audits(targets) do
|
||||||
|
allowed =
|
||||||
|
[
|
||||||
|
{"user", targets.users},
|
||||||
|
{"request", targets.requests},
|
||||||
|
{"assignment", targets.assignments},
|
||||||
|
{"activity", targets.activities},
|
||||||
|
{"activity_participant", targets.activity_participants},
|
||||||
|
{"category_proposal", targets.proposals},
|
||||||
|
{"report", targets.reports},
|
||||||
|
{"abuse_signal", targets.signals}
|
||||||
|
]
|
||||||
|
|> Enum.flat_map(fn {type, ids} -> Enum.map(ids, &{type, &1}) end)
|
||||||
|
|> MapSet.new()
|
||||||
|
|
||||||
|
unexpected =
|
||||||
|
AuditEvent
|
||||||
|
|> where([event], event.actor_id in ^targets.users)
|
||||||
|
|> select([event], {event.target_type, event.target_id, event.action})
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.reject(fn {type, id, _action} -> MapSet.member?(allowed, {type, id}) end)
|
||||||
|
|
||||||
|
if unexpected != [] do
|
||||||
|
Mix.raise(
|
||||||
|
"run-scoped users produced audit events outside fixture data: #{inspect(unexpected)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fixture_push_job_ids(user_ids) do
|
||||||
|
recipients = Enum.map(user_ids, &"user:#{&1}")
|
||||||
|
|
||||||
|
Job
|
||||||
|
|> where(
|
||||||
|
[job],
|
||||||
|
job.worker == "WhoNeedHelp.Push.DeliveryWorker" and
|
||||||
|
fragment("?->>'recipient' = ANY(?)", job.args, ^recipients)
|
||||||
|
)
|
||||||
|
|> select([job], job.id)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_prefix_unused!(context) do
|
||||||
|
if Repo.exists?(from user in User, where: like(user.email, ^"#{prefix(context.run_id)}%")) do
|
||||||
|
Mix.raise("staging E2E users still exist for #{inspect(context.run_id)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_only_allowed_prefix_users!(context) do
|
||||||
|
allowed = Map.values(context.emails)
|
||||||
|
|
||||||
|
unexpected =
|
||||||
|
User
|
||||||
|
|> where([user], like(user.email, ^"#{prefix(context.run_id)}%"))
|
||||||
|
|> where([user], user.email not in ^allowed)
|
||||||
|
|> select([user], user.email)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
if unexpected != [] do
|
||||||
|
Mix.raise("unexpected users share the staging run prefix: #{inspect(unexpected)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ids(schema, field, parent_ids) do
|
||||||
|
schema
|
||||||
|
|> where([row], field(row, ^field) in ^parent_ids)
|
||||||
|
|> select([row], row.id)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp delete_ids(_schema, []), do: 0
|
||||||
|
|
||||||
|
defp delete_ids(schema, ids) do
|
||||||
|
schema
|
||||||
|
|> where([row], row.id in ^ids)
|
||||||
|
|> delete_count()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp delete_count(query), do: query |> Repo.delete_all() |> elem(0)
|
||||||
|
|
||||||
|
defp insert_user!(email, display_name, password_hash, role, now) do
|
||||||
|
%User{}
|
||||||
|
|> User.registration_changeset(%{
|
||||||
|
"email" => email,
|
||||||
|
"display_name" => display_name,
|
||||||
|
"locale" => "en",
|
||||||
|
"terms_accepted" => true
|
||||||
|
})
|
||||||
|
|> Ecto.Changeset.put_change(:hashed_password, password_hash)
|
||||||
|
|> Ecto.Changeset.put_change(:confirmed_at, now)
|
||||||
|
|> Ecto.Changeset.put_change(:role, role)
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp emails(run_id) do
|
||||||
|
(@precreated_roles ++ @registered_roles)
|
||||||
|
|> Map.new(fn role -> {role, "#{prefix(run_id)}#{role}@example.invalid"} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prefix(run_id), do: "wnh-staging-e2e-#{run_id}-"
|
||||||
|
|
||||||
|
defp display_name(role) do
|
||||||
|
role
|
||||||
|
|> String.replace("-", " ")
|
||||||
|
|> String.split()
|
||||||
|
|> Enum.map_join(" ", &String.capitalize/1)
|
||||||
|
|> then(&"Staging E2E #{&1}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp required_env!(name) do
|
||||||
|
case System.get_env(name) do
|
||||||
|
value when is_binary(value) and value != "" -> value
|
||||||
|
_ -> Mix.raise("#{name} is required")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp uuid?(value) when is_binary(value) do
|
||||||
|
match?({:ok, _}, Ecto.UUID.cast(value))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp uuid?(_value), do: false
|
||||||
|
end
|
||||||
227
scripts/staging-full-e2e-run.sh
Executable file
227
scripts/staging-full-e2e-run.sh
Executable file
|
|
@ -0,0 +1,227 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
ENV_FILE="$ROOT/.env"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing $ENV_FILE." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}"
|
||||||
|
: "${PHX_HOST:?PHX_HOST is missing from .env}"
|
||||||
|
: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}"
|
||||||
|
: "${PHX_URL_PORT:?PHX_URL_PORT is missing from .env}"
|
||||||
|
|
||||||
|
if [[ "$PHX_SCHEME" != https ]]; then
|
||||||
|
echo "Public staging E2E requires PHX_SCHEME=https." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
||||||
|
output_dir="$ROOT/output/staging-full-e2e/$run_id"
|
||||||
|
base_url="${PHX_SCHEME}://${PHX_HOST}"
|
||||||
|
mailpit_url=http://127.0.0.1:8027
|
||||||
|
admin_email="wnh-staging-e2e-$run_id-admin@example.invalid"
|
||||||
|
|
||||||
|
if [[ "$PHX_URL_PORT" != 443 ]]; then
|
||||||
|
base_url="${base_url}:${PHX_URL_PORT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
chmod 700 "$ROOT/output" "$ROOT/output/staging-full-e2e" "$output_dir"
|
||||||
|
|
||||||
|
fixture_password=$(openssl rand -base64 36 | tr -d '\n')
|
||||||
|
prepared=0
|
||||||
|
|
||||||
|
db_container=$(docker compose ps -q db)
|
||||||
|
|
||||||
|
if [[ -z "$db_container" ]]; then
|
||||||
|
echo "The ordinary Compose database container is not running." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
internal_network_id=$(
|
||||||
|
docker inspect "$db_container" |
|
||||||
|
jq -r '.[0].NetworkSettings.Networks | to_entries[] | select(.key | endswith("_internal")) | .value.NetworkID' |
|
||||||
|
head -n 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -z "$internal_network_id" ]]; then
|
||||||
|
echo "Could not identify the ordinary Compose internal network." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
snapshot_database() {
|
||||||
|
local destination=$1
|
||||||
|
|
||||||
|
docker compose exec -T db sh -c \
|
||||||
|
'psql --no-psqlrc --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \
|
||||||
|
>"$destination" <<'SQL'
|
||||||
|
BEGIN READ ONLY;
|
||||||
|
SELECT 'users' AS table_name, count(*) AS row_count FROM users
|
||||||
|
UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens
|
||||||
|
UNION ALL SELECT 'help_requests', count(*) FROM help_requests
|
||||||
|
UNION ALL SELECT 'messages', count(*) FROM messages
|
||||||
|
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments
|
||||||
|
UNION ALL SELECT 'reports', count(*) FROM reports
|
||||||
|
UNION ALL SELECT 'reviews', count(*) FROM reviews
|
||||||
|
UNION ALL SELECT 'audit_events', count(*) FROM audit_events
|
||||||
|
UNION ALL SELECT 'abuse_signals', count(*) FROM abuse_signals
|
||||||
|
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
|
||||||
|
UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions
|
||||||
|
UNION ALL SELECT 'activities', count(*) FROM activities
|
||||||
|
UNION ALL SELECT 'activity_participants', count(*) FROM activity_participants
|
||||||
|
UNION ALL SELECT 'activity_messages', count(*) FROM activity_messages
|
||||||
|
UNION ALL SELECT 'category_proposals', count(*) FROM category_proposals
|
||||||
|
UNION ALL SELECT 'category_votes', count(*) FROM category_votes
|
||||||
|
UNION ALL SELECT 'blocks', count(*) FROM blocks
|
||||||
|
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
|
||||||
|
UNION ALL SELECT 'rate_limit_buckets', count(*) FROM rate_limit_buckets
|
||||||
|
ORDER BY table_name;
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
}
|
||||||
|
|
||||||
|
run_fixture_tool() {
|
||||||
|
local action=$1
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--network "$internal_network_id" \
|
||||||
|
--env-file "$ENV_FILE" \
|
||||||
|
--env APP_ROLE=migrate \
|
||||||
|
--env "WNH_STAGING_E2E_EXPECTED_DATABASE=$POSTGRES_DB" \
|
||||||
|
--env WNH_STAGING_E2E_CONFIRM=public-staging-full-e2e \
|
||||||
|
--env "WNH_STAGING_E2E_RUN_ID=$run_id" \
|
||||||
|
--env "WNH_STAGING_E2E_PASSWORD=$fixture_password" \
|
||||||
|
--env WNH_STAGING_E2E_MANIFEST_PATH=/output/fixture.json \
|
||||||
|
--volume "$output_dir:/output" \
|
||||||
|
who-need-help:staging-e2e-tools \
|
||||||
|
mix wnh.staging_full_e2e "$action"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_mailpit() {
|
||||||
|
local ids_file="$output_dir/mailpit-message-ids.txt"
|
||||||
|
local payload_file="$output_dir/mailpit-delete.json"
|
||||||
|
local role
|
||||||
|
|
||||||
|
: >"$ids_file"
|
||||||
|
|
||||||
|
for role in \
|
||||||
|
requester \
|
||||||
|
helper \
|
||||||
|
activity-organizer \
|
||||||
|
activity-participant \
|
||||||
|
admin \
|
||||||
|
auth-user \
|
||||||
|
auth-user-changed
|
||||||
|
do
|
||||||
|
curl --fail --silent --show-error --get "$mailpit_url/api/v1/search" \
|
||||||
|
--data-urlencode "query=to:\"wnh-staging-e2e-$run_id-$role@example.invalid\"" \
|
||||||
|
--data-urlencode limit=100 |
|
||||||
|
jq -r '.messages[]?.ID' >>"$ids_file"
|
||||||
|
done
|
||||||
|
|
||||||
|
sort -u -o "$ids_file" "$ids_file"
|
||||||
|
jq -Rn '[inputs | select(length > 0)] | {IDs: .}' <"$ids_file" >"$payload_file"
|
||||||
|
|
||||||
|
if [[ "$(jq '.IDs | length' "$payload_file")" -gt 0 ]]; then
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--request DELETE \
|
||||||
|
--header 'content-type: application/json' \
|
||||||
|
--data-binary "@$payload_file" \
|
||||||
|
"$mailpit_url/api/v1/messages" >"$output_dir/mailpit-delete-response.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for role in \
|
||||||
|
requester \
|
||||||
|
helper \
|
||||||
|
activity-organizer \
|
||||||
|
activity-participant \
|
||||||
|
admin \
|
||||||
|
auth-user \
|
||||||
|
auth-user-changed
|
||||||
|
do
|
||||||
|
remaining=$(
|
||||||
|
curl --fail --silent --show-error --get "$mailpit_url/api/v1/search" \
|
||||||
|
--data-urlencode "query=to:\"wnh-staging-e2e-$run_id-$role@example.invalid\"" \
|
||||||
|
--data-urlencode limit=1 |
|
||||||
|
jq '.messages | length'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$remaining" -ne 0 ]]; then
|
||||||
|
echo "Mailpit still contains run-scoped messages for $role." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
if [[ "$prepared" -eq 1 ]]; then
|
||||||
|
if ! run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then
|
||||||
|
echo "Exact full staging E2E cleanup failed; inspect $output_dir." >&2
|
||||||
|
status=1
|
||||||
|
else
|
||||||
|
snapshot_database "$output_dir/database-after.txt"
|
||||||
|
|
||||||
|
if ! diff -u \
|
||||||
|
"$output_dir/database-before.txt" \
|
||||||
|
"$output_dir/database-after.txt" \
|
||||||
|
>"$output_dir/database-cleanup.diff"; then
|
||||||
|
echo "Full staging E2E cleanup did not restore application table counts." >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! cleanup_mailpit >"$output_dir/mailpit-cleanup.log" 2>&1; then
|
||||||
|
echo "Exact full staging E2E Mailpit cleanup failed; inspect $output_dir." >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset fixture_password
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
curl --fail --silent --show-error "$base_url/healthz/ready" >"$output_dir/public-ready.txt"
|
||||||
|
snapshot_database "$output_dir/database-before.txt"
|
||||||
|
|
||||||
|
docker build --target load_tools --tag who-need-help:staging-e2e-tools . \
|
||||||
|
>"$output_dir/tools-build.log"
|
||||||
|
docker build --tag who-need-help-e2e-tests:local e2e \
|
||||||
|
>"$output_dir/browser-build.log"
|
||||||
|
|
||||||
|
run_fixture_tool prepare >"$output_dir/fixture-prepare.log"
|
||||||
|
prepared=1
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--env "BASE_URL=$base_url" \
|
||||||
|
--env "MAILPIT_URL=$mailpit_url" \
|
||||||
|
--env "E2E_RUN_ID=$run_id" \
|
||||||
|
--env "E2E_FIXTURE_PASSWORD=$fixture_password" \
|
||||||
|
--env "E2E_ADMIN_EMAIL=$admin_email" \
|
||||||
|
--env HOME=/tmp \
|
||||||
|
--volume "$output_dir:/work/output" \
|
||||||
|
who-need-help-e2e-tests:local \
|
||||||
|
npx playwright test --project=chromium \
|
||||||
|
tests/auth-settings.spec.ts \
|
||||||
|
tests/mutual-aid.spec.ts \
|
||||||
|
tests/activity-moderation.spec.ts
|
||||||
|
|
||||||
|
echo "Full public staging E2E passed: $base_url"
|
||||||
|
echo "Evidence: $output_dir"
|
||||||
Loading…
Reference in New Issue
Block a user