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:
Ullrich Schäfer 2026-04-19 10:14:58 +02:00
parent 7304a9e904
commit ba4c4e1b4f
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
16 changed files with 1003 additions and 43 deletions

116
e2e/demo-bot.test.ts Normal file
View 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();
}
}
});
});