trails/apps/journal/app/lib/explore.integration.test.ts
Ullrich Schäfer 37f7a349b9 Implement /explore: local user discovery directory
Stream F implementation. Closes the gap between "I want to follow
someone on this instance" and "I have a username from outside the
app." Anonymous visitors and signed-in users both reach a paginated
directory of local public users; signed-in viewers also see Follow
buttons inline.

- apps/journal/app/lib/explore.server.ts — listDirectory (paginated,
  ordered by MAX(public-activity created_at) DESC NULLS LAST,
  tiebreaker users.id DESC), listActiveRecently (top 5 in last 30
  days), countDirectory, batched countFollowersBatch and
  getFollowStateBatch helpers so the page issues two extra queries
  regardless of page size. Excludes private profiles and the demo
  persona; banned/suspended scaffolding noted for forward-compat.
- apps/journal/app/routes/explore.tsx — loader fans out the four
  queries in parallel; component renders an "Active recently" strip
  (hidden when empty) above the main directory; FollowButton inlines
  per row for signed-in viewers. Bio truncated to 120 chars.
- apps/journal/app/routes.ts — register /explore.
- apps/journal/app/root.tsx — add Explore navbar entry for signed-in
  users.
- apps/journal/app/routes/home.tsx — visitor home gains a secondary
  "Browse who's here →" link to /explore alongside the Planner
  escape hatch.
- packages/i18n/src/locales/{en,de}.ts — explore.*, nav.explore,
  home.exploreLink keys.
- apps/journal/app/lib/explore.integration.test.ts — opt-in
  (EXPLORE_INTEGRATION=1) integration tests covering inclusion,
  exclusion, ordering, NULLS LAST behavior, "Active recently"
  30-day filter, count helpers.
- e2e/explore.test.ts — anonymous loads /explore (no auth needed);
  private profile is excluded from the directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:24:19 +02:00

180 lines
6.7 KiB
TypeScript

import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
import { activities, users } from "@trails-cool/db/schema/journal";
import {
listDirectory,
countDirectory,
listActiveRecently,
countFollowersBatch,
} from "./explore.server.ts";
import { loadPersona } from "./demo-bot.server.ts";
// Opt-in: these talk to real Postgres. Gated by EXPLORE_INTEGRATION=1.
const runIntegration = process.env.EXPLORE_INTEGRATION === "1";
async function makeUser(opts: {
username: string;
profileVisibility?: "public" | "private";
bio?: string | null;
}) {
const db = getDb();
const id = randomUUID();
await db.insert(users).values({
id,
email: `${opts.username}@example.test`,
username: opts.username,
domain: "test.local",
bio: opts.bio ?? null,
profileVisibility: opts.profileVisibility ?? "public",
});
return id;
}
async function makeActivity(opts: {
ownerId: string;
visibility?: "public" | "unlisted" | "private";
createdAt?: Date;
name?: string;
}) {
const db = getDb();
const id = randomUUID();
await db.insert(activities).values({
id,
ownerId: opts.ownerId,
name: opts.name ?? `act-${id.slice(0, 8)}`,
visibility: opts.visibility ?? "public",
createdAt: opts.createdAt ?? new Date(),
});
return id;
}
async function wipe() {
const db = getDb();
await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`);
}
describe.skipIf(!runIntegration)("explore.server integration", () => {
beforeAll(async () => {
const db = getDb();
await db.execute(sql`SELECT 1`);
});
afterEach(wipe);
it("public user appears in the directory", async () => {
const id = await makeUser({ username: `ex_pub_${Date.now()}` });
const { rows, totalCount } = await listDirectory({ page: 1, perPage: 50 });
expect(rows.find((r) => r.id === id)).toBeDefined();
expect(totalCount).toBeGreaterThanOrEqual(1);
});
it("private user is excluded from the directory", async () => {
const id = await makeUser({
username: `ex_priv_${Date.now()}`,
profileVisibility: "private",
});
const { rows } = await listDirectory({ page: 1, perPage: 50 });
expect(rows.find((r) => r.id === id)).toBeUndefined();
});
it("demo persona is excluded from the directory", async () => {
const persona = loadPersona();
// Insert a user with the persona's username — should still be filtered out.
const id = await makeUser({ username: persona.username });
const { rows } = await listDirectory({ page: 1, perPage: 50 });
expect(rows.find((r) => r.id === id)).toBeUndefined();
});
it("orders by most-recent public activity, NULLS LAST", async () => {
const stamp = Date.now();
const aId = await makeUser({ username: `ex_a_${stamp}` });
const bId = await makeUser({ username: `ex_b_${stamp}` });
// A has yesterday, B has today.
await makeActivity({ ownerId: aId, createdAt: new Date(Date.now() - 86_400_000) });
await makeActivity({ ownerId: bId, createdAt: new Date() });
const cId = await makeUser({ username: `ex_c_${stamp}` }); // no activities
const { rows } = await listDirectory({ page: 1, perPage: 50 });
const indexOf = (id: string) => rows.findIndex((r) => r.id === id);
expect(indexOf(bId)).toBeGreaterThanOrEqual(0);
expect(indexOf(aId)).toBeGreaterThan(indexOf(bId)); // B before A
expect(indexOf(cId)).toBeGreaterThan(indexOf(aId)); // A before C (C is NULL)
});
it("private activities don't count for ordering", async () => {
const stamp = Date.now();
const aId = await makeUser({ username: `ex_priv_act_a_${stamp}` });
const bId = await makeUser({ username: `ex_priv_act_b_${stamp}` });
// A has a private activity from today; B has a public activity from yesterday.
await makeActivity({ ownerId: aId, visibility: "private", createdAt: new Date() });
await makeActivity({
ownerId: bId,
visibility: "public",
createdAt: new Date(Date.now() - 86_400_000),
});
const { rows } = await listDirectory({ page: 1, perPage: 50 });
const indexOf = (id: string) => rows.findIndex((r) => r.id === id);
// B (public yesterday) should come before A (private today is invisible).
expect(indexOf(bId)).toBeGreaterThanOrEqual(0);
expect(indexOf(aId)).toBeGreaterThan(indexOf(bId));
});
it("'Active recently' includes public users with public activity in last 30 days", async () => {
const stamp = Date.now();
const aId = await makeUser({ username: `ex_ar_a_${stamp}` });
const bId = await makeUser({ username: `ex_ar_b_${stamp}` });
// A has a public activity from 5 days ago.
await makeActivity({
ownerId: aId,
visibility: "public",
createdAt: new Date(Date.now() - 5 * 86_400_000),
});
// B has a public activity from 60 days ago.
await makeActivity({
ownerId: bId,
visibility: "public",
createdAt: new Date(Date.now() - 60 * 86_400_000),
});
const rows = await listActiveRecently(10);
expect(rows.find((r) => r.id === aId)).toBeDefined();
expect(rows.find((r) => r.id === bId)).toBeUndefined();
});
it("'Active recently' caps at the requested limit", async () => {
const stamp = Date.now();
const ids: string[] = [];
for (let i = 0; i < 7; i++) {
const id = await makeUser({ username: `ex_cap_${i}_${stamp}` });
await makeActivity({
ownerId: id,
visibility: "public",
createdAt: new Date(Date.now() - i * 60_000),
});
ids.push(id);
}
const rows = await listActiveRecently(3);
expect(rows.length).toBeLessThanOrEqual(3);
});
it("countDirectory matches listDirectory's totalCount", async () => {
await makeUser({ username: `ex_count_a_${Date.now()}` });
await makeUser({ username: `ex_count_b_${Date.now()}` });
const total = await countDirectory();
const { totalCount } = await listDirectory({ page: 1, perPage: 1 });
expect(total).toBe(totalCount);
});
it("countFollowersBatch handles empty input", async () => {
const result = await countFollowersBatch([]);
expect(result.size).toBe(0);
});
it("countFollowersBatch fills zeros for users with no followers", async () => {
const id = await makeUser({ username: `ex_fb_${Date.now()}` });
const result = await countFollowersBatch([id]);
expect(result.get(id)).toBe(0);
});
});