Apply demo-activity-bot: Bruno, the synthetic public-walk generator
Adds a background demo user (`bruno`) plus two pg-boss jobs that generate public trekking routes and activities in inner Berlin and prune them after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED` only in prod. - Schema: `synthetic` boolean on routes + activities - `ensureDemoUser()` idempotent insert + badge on /users/bruno - Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d - Initial 4-walk backfill on first enable; retention via daily prune - Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total` - Unit + DB-gated integration + E2E tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7304a9e904
commit
ba4c4e1b4f
16 changed files with 1003 additions and 43 deletions
116
e2e/demo-bot.test.ts
Normal file
116
e2e/demo-bot.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import postgres from "postgres";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Demo-bot UX: a browser-side verification that a pre-seeded `bruno`
|
||||
// profile renders the demo badge and surfaces his public content to an
|
||||
// anonymous visitor. We seed directly via SQL rather than running the
|
||||
// pg-boss handler, because the handler depends on BRouter and on the
|
||||
// demo flag being live in the server process. The point of this test
|
||||
// is the *public surface*, not the generation path (that's covered in
|
||||
// app/lib/demo-bot.integration.test.ts).
|
||||
|
||||
const CONN = process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails";
|
||||
|
||||
async function seedBrunoWithSyntheticRoute(): Promise<{ username: string; routeName: string }> {
|
||||
const sql = postgres(CONN, { max: 1 });
|
||||
const username = `bruno-e2e-${Date.now()}`;
|
||||
const routeName = `Bruno's ${Date.now()} walk`;
|
||||
const userId = randomUUID();
|
||||
const routeId = randomUUID();
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO journal.users (id, email, username, display_name, bio, domain, terms_accepted_at, terms_version)
|
||||
VALUES (${userId}, ${`${username}@example.com`}, ${username}, 'Bruno', 'Professional park inspector.', 'localhost', now(), '2026-04-19')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO journal.routes (id, owner_id, name, description, visibility, synthetic, distance, created_at, updated_at)
|
||||
VALUES (${routeId}, ${userId}, ${routeName}, 'Sniffed three bushes.', 'public', true, 3200, now(), now())
|
||||
`;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
return { username, routeName };
|
||||
}
|
||||
|
||||
async function cleanupBruno(username: string) {
|
||||
const sql = postgres(CONN, { max: 1 });
|
||||
try {
|
||||
const [row] = await sql<{ id: string }[]>`SELECT id FROM journal.users WHERE username = ${username}`;
|
||||
if (!row) return;
|
||||
await sql`DELETE FROM journal.activities WHERE owner_id = ${row.id}`;
|
||||
await sql`DELETE FROM journal.routes WHERE owner_id = ${row.id}`;
|
||||
await sql`DELETE FROM journal.users WHERE id = ${row.id}`;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("Demo-bot UX", () => {
|
||||
test("seeded bruno profile shows demo badge + public route to anon visitor", async ({ browser }) => {
|
||||
const { username, routeName } = await seedBrunoWithSyntheticRoute();
|
||||
try {
|
||||
const anonCtx = await browser.newContext();
|
||||
const anon = await anonCtx.newPage();
|
||||
const resp = await anon.goto(`/users/${username}`);
|
||||
expect(resp?.status()).toBe(200);
|
||||
|
||||
await expect(anon.getByRole("heading", { name: /Bruno/ })).toBeVisible();
|
||||
// The demo badge is only rendered when username === 'bruno' — our
|
||||
// seeded username includes a timestamp suffix, so the badge
|
||||
// shouldn't appear. This is an intentional guard: only the *real*
|
||||
// bruno gets the badge. The rest of the page still works.
|
||||
await expect(anon.getByText("🐕 Demo account")).toHaveCount(0);
|
||||
|
||||
// Public synthetic route is listed on the profile.
|
||||
await expect(anon.getByRole("link", { name: new RegExp(routeName) })).toBeVisible();
|
||||
await anonCtx.close();
|
||||
} finally {
|
||||
await cleanupBruno(username);
|
||||
}
|
||||
});
|
||||
|
||||
test("profile rendered at /users/bruno shows the demo badge when username is exactly 'bruno'", async ({ browser }) => {
|
||||
const sql = postgres(CONN, { max: 1 });
|
||||
const userId = randomUUID();
|
||||
const routeId = randomUUID();
|
||||
// Use ON CONFLICT so a pre-existing prod `bruno` user doesn't block
|
||||
// the test; we don't clean up a row we didn't create.
|
||||
const created = await sql`
|
||||
INSERT INTO journal.users (id, email, username, display_name, bio, domain, terms_accepted_at, terms_version)
|
||||
VALUES (${userId}, 'bruno@localhost', 'bruno', 'Bruno', 'Professional park inspector.', 'localhost', now(), '2026-04-19')
|
||||
ON CONFLICT (username) DO NOTHING
|
||||
RETURNING id
|
||||
`;
|
||||
const weCreatedIt = created.length > 0;
|
||||
const effectiveUserId = weCreatedIt ? userId : (await sql`SELECT id FROM journal.users WHERE username='bruno'`)[0]!.id as string;
|
||||
|
||||
await sql`
|
||||
INSERT INTO journal.routes (id, owner_id, name, description, visibility, synthetic, distance, created_at, updated_at)
|
||||
VALUES (${routeId}, ${effectiveUserId}, 'Grunewald patrol', 'Sniffed three bushes.', 'public', true, 3200, now(), now())
|
||||
`;
|
||||
await sql.end();
|
||||
|
||||
try {
|
||||
const anonCtx = await browser.newContext();
|
||||
const anon = await anonCtx.newPage();
|
||||
const resp = await anon.goto(`/users/bruno`);
|
||||
expect(resp?.status()).toBe(200);
|
||||
await expect(anon.getByText(/Demo account|Demo-Konto/)).toBeVisible();
|
||||
await expect(anon.getByRole("link", { name: /Grunewald patrol/ })).toBeVisible();
|
||||
await anonCtx.close();
|
||||
} finally {
|
||||
const cleanup = postgres(CONN, { max: 1 });
|
||||
try {
|
||||
await cleanup`DELETE FROM journal.routes WHERE id = ${routeId}`;
|
||||
if (weCreatedIt) {
|
||||
await cleanup`DELETE FROM journal.users WHERE id = ${userId}`;
|
||||
}
|
||||
} finally {
|
||||
await cleanup.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue