Skip to content

Commit aef7e46

Browse files
authored
Merge branch 'main' into codex/milestone-5-beta-docs
2 parents 76a4fdb + c8e2b67 commit aef7e46

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+2895
-742
lines changed

AGENTS.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ This is a Next.js App Router project with TypeScript, NextAuth, PostgreSQL, Vite
1414
- `components/clubs/`: club cards, boards, and book-assignment forms.
1515
- `components/ui/`: shared primitives (`button`, `input`, `textarea`, `card`, `badge`).
1616
- `lib/`: business and integration logic.
17-
- `lib/auth/`: NextAuth options, session helpers, user persistence, secrets, and e2e auth helpers.
17+
- `lib/auth/`: NextAuth options, session helpers, user persistence, and secrets.
1818
- `lib/books/`: Google Books API integration, description/volume normalization, and repository access.
1919
- `lib/clubs/`: validation, permissions, presentation helpers, repository access, and domain errors.
20-
- `lib/test/fixtures.ts`: shared test fixtures.
20+
- `lib/test-harness/`: runtime-safe e2e/test harness helpers, fixtures, and deterministic Google Books mock data.
2121
- `lib/db.ts`: PostgreSQL connection.
2222
- `tests/`: Vitest coverage split into `tests/unit/` and `tests/integration/`.
2323
- `e2e/`: Playwright specs and helpers.
@@ -75,8 +75,7 @@ Always treat these as quality gates for substantial changes:
7575

7676
## Security & Configuration Tips
7777
- Keep secrets in `.env.local`; never commit credentials.
78-
- Required env for the full app flow: `DATABASE_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_BOOKS_API_KEY`, `AUTH_SECRET` (or `NEXTAUTH_SECRET`).
79-
- Set `NEXTAUTH_URL` in runtime environments to avoid NextAuth callback and URL issues.
78+
- Required env for the full app flow: `DATABASE_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_BOOKS_API_KEY`, `AUTH_SECRET` (or `NEXTAUTH_SECRET`), and `NEXTAUTH_URL` for production-like or e2e-authenticated runs. `GOOGLE_BOOKS_API_BASE_URL` is optional and intended for test/local upstream overrides.
8079
- `E2E_BYPASS_AUTH=1` is test-only; do not enable it in normal development or production.
8180
- `app/api/test/*` routes are intentionally public only under e2e bypass mode; preserve that constraint.
8281
- Do not edit `.next/`, `node_modules/`, `playwright-report/`, or `test-results/`; these are generated artifacts.

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,17 @@ GOOGLE_CLIENT_SECRET=...
1717
GOOGLE_BOOKS_API_KEY=...
1818

1919
DATABASE_URL=postgres://...
20-
21-
RATE_LIMIT_PROVIDER=<redis|upstash>
22-
UPSTASH_REDIS_REST_URL=...
23-
UPSTASH_REDIS_REST_TOKEN=...
2420
```
2521

2622
Optional:
2723

2824
```bash
2925
NEXTAUTH_URL=http://localhost:3000
3026
NEXTAUTH_SECRET=...
27+
RATE_LIMIT_PROVIDER=<disabled|memory|redis|upstash>
28+
RATE_LIMIT_REDIS_URL=redis://...
29+
UPSTASH_REDIS_REST_URL=...
30+
UPSTASH_REDIS_REST_TOKEN=...
3131
```
3232

3333
Generate a strong secret:
@@ -97,6 +97,8 @@ REMOVE_VOLUME=1
9797
pnpm dev
9898
```
9999

100+
`next dev`, `next build`, and `next start` now validate the required application environment before the app fully boots. Missing or invalid values fail fast with one aggregated error message.
101+
100102
When you run `npm start` or `pnpm start`, a `prestart` hook now verifies that local PostgreSQL is reachable at `localhost:54329` when `DATABASE_URL` targets the local dev database. If it is not running, start it with:
101103

102104
```bash

app/api/test/auth/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { NextRequest, NextResponse } from "next/server";
22

3-
import { E2E_AUTH_COOKIE_NAME } from "@/lib/auth/constants";
4-
import { isE2EBypassEnabled } from "@/lib/auth/e2e";
3+
import {
4+
E2E_AUTH_COOKIE_NAME,
5+
isE2EBypassEnabled,
6+
} from "@/lib/test-harness/auth";
57
import {
68
getTestUser,
79
seedTestUsers,
8-
} from "@/lib/test/fixtures";
10+
} from "@/lib/test-harness/fixtures";
911
import {
1012
E2E_DEFAULT_RETURN_TO,
1113
TEST_ROUTE_ERROR_MESSAGES,
1214
isTestUserKey,
13-
} from "@/lib/test/constants";
15+
} from "@/lib/test-harness/constants";
1416

1517
export const runtime = "nodejs";
1618

app/api/test/club-books/route.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { z } from "zod";
3+
4+
import { findUserByProviderIdentity } from "@/lib/auth/users";
5+
import { findBookByGoogleVolumeId } from "@/lib/books/repository";
6+
import { addBookToClub, removeClubBook } from "@/lib/clubs/repository";
7+
import {
8+
E2E_USER_PROVIDER,
9+
isE2EBypassEnabled,
10+
} from "@/lib/test-harness/auth";
11+
import {
12+
TEST_ROUTE_ERROR_MESSAGES,
13+
TEST_USER_KEYS,
14+
} from "@/lib/test-harness/constants";
15+
import {
16+
TEST_BOOK_VOLUME_ID,
17+
seedTestUsers,
18+
} from "@/lib/test-harness/fixtures";
19+
20+
export const runtime = "nodejs";
21+
22+
const CLUB_BOOK_STATUSES = ["WANT_TO_READ", "READING", "READ"] as const;
23+
24+
const seedClubBookSchema = z.object({
25+
kind: z.literal("add-book"),
26+
clubId: z.string().min(1),
27+
googleVolumeId: z.string().min(1).default(TEST_BOOK_VOLUME_ID),
28+
status: z.enum(CLUB_BOOK_STATUSES).default("WANT_TO_READ"),
29+
user: z.enum(TEST_USER_KEYS),
30+
});
31+
32+
const archiveClubBookSchema = z.object({
33+
kind: z.literal("remove-book"),
34+
clubId: z.string().min(1),
35+
clubBookId: z.string().min(1),
36+
user: z.enum(TEST_USER_KEYS),
37+
});
38+
39+
const clubBookPayloadSchema = z.union([
40+
seedClubBookSchema,
41+
archiveClubBookSchema,
42+
]);
43+
44+
export async function POST(request: NextRequest) {
45+
if (!isE2EBypassEnabled()) {
46+
return NextResponse.json(
47+
{ error: TEST_ROUTE_ERROR_MESSAGES.notAvailable },
48+
{ status: 404 },
49+
);
50+
}
51+
52+
const parsedBody = clubBookPayloadSchema.safeParse(
53+
await request.json().catch(() => null),
54+
);
55+
if (!parsedBody.success) {
56+
return NextResponse.json(
57+
{ error: TEST_ROUTE_ERROR_MESSAGES.invalidSeedPayload },
58+
{ status: 400 },
59+
);
60+
}
61+
62+
await seedTestUsers();
63+
const user = await findUserByProviderIdentity(
64+
E2E_USER_PROVIDER,
65+
parsedBody.data.user,
66+
);
67+
if (!user) {
68+
return NextResponse.json(
69+
{ error: TEST_ROUTE_ERROR_MESSAGES.unknownTestUser },
70+
{ status: 400 },
71+
);
72+
}
73+
74+
if (parsedBody.data.kind === "remove-book") {
75+
await removeClubBook({
76+
clubId: parsedBody.data.clubId,
77+
clubBookId: parsedBody.data.clubBookId,
78+
removedById: user.id,
79+
});
80+
81+
return NextResponse.json({ ok: true });
82+
}
83+
84+
const book = await findBookByGoogleVolumeId(parsedBody.data.googleVolumeId);
85+
if (!book) {
86+
return NextResponse.json(
87+
{ error: TEST_ROUTE_ERROR_MESSAGES.invalidSeedPayload },
88+
{ status: 400 },
89+
);
90+
}
91+
92+
const clubBook = await addBookToClub({
93+
clubId: parsedBody.data.clubId,
94+
bookId: book.id,
95+
addedById: user.id,
96+
status: parsedBody.data.status,
97+
});
98+
99+
return NextResponse.json({ ok: true, clubBookId: clubBook.id });
100+
}

app/api/test/reset/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { NextResponse } from "next/server";
22

3-
import { isE2EBypassEnabled } from "@/lib/auth/e2e";
43
import { resetMemoryMutationRateLimitStore } from "@/lib/rate-limit/mutation";
5-
import { resetTestDatabase } from "@/lib/test/fixtures";
6-
import { TEST_ROUTE_ERROR_MESSAGES } from "@/lib/test/constants";
4+
import { isE2EBypassEnabled } from "@/lib/test-harness/auth";
5+
import { TEST_ROUTE_ERROR_MESSAGES } from "@/lib/test-harness/constants";
6+
import { resetTestDatabase } from "@/lib/test-harness/fixtures";
77

88
export const runtime = "nodejs";
99

app/api/test/reviews/route.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { z } from "zod";
3+
4+
import { findUserByProviderIdentity } from "@/lib/auth/users";
5+
import { findBookByGoogleVolumeId } from "@/lib/books/repository";
6+
import { upsertReview } from "@/lib/reviews/repository";
7+
import { parseReviewBody, parseReviewRating, parseReviewTitle } from "@/lib/reviews/validation";
8+
import {
9+
E2E_USER_PROVIDER,
10+
isE2EBypassEnabled,
11+
} from "@/lib/test-harness/auth";
12+
import {
13+
TEST_ROUTE_ERROR_MESSAGES,
14+
TEST_USER_KEYS,
15+
} from "@/lib/test-harness/constants";
16+
import {
17+
TEST_BOOK_VOLUME_ID,
18+
seedTestUsers,
19+
} from "@/lib/test-harness/fixtures";
20+
21+
export const runtime = "nodejs";
22+
23+
const seedReviewSchema = z.object({
24+
kind: z.literal("upsert"),
25+
googleVolumeId: z.string().min(1).default(TEST_BOOK_VOLUME_ID),
26+
user: z.enum(TEST_USER_KEYS),
27+
rating: z.number().min(0.5).max(5),
28+
title: z.string().max(120).default(""),
29+
body: z.string().max(5000).default(""),
30+
});
31+
32+
export async function POST(request: NextRequest) {
33+
if (!isE2EBypassEnabled()) {
34+
return NextResponse.json(
35+
{ error: TEST_ROUTE_ERROR_MESSAGES.notAvailable },
36+
{ status: 404 },
37+
);
38+
}
39+
40+
const parsedBody = seedReviewSchema.safeParse(
41+
await request.json().catch(() => null),
42+
);
43+
if (!parsedBody.success) {
44+
return NextResponse.json(
45+
{ error: TEST_ROUTE_ERROR_MESSAGES.invalidSeedPayload },
46+
{ status: 400 },
47+
);
48+
}
49+
50+
await seedTestUsers();
51+
const user = await findUserByProviderIdentity(
52+
E2E_USER_PROVIDER,
53+
parsedBody.data.user,
54+
);
55+
if (!user) {
56+
return NextResponse.json(
57+
{ error: TEST_ROUTE_ERROR_MESSAGES.unknownTestUser },
58+
{ status: 400 },
59+
);
60+
}
61+
62+
const book = await findBookByGoogleVolumeId(parsedBody.data.googleVolumeId);
63+
if (!book) {
64+
return NextResponse.json(
65+
{ error: TEST_ROUTE_ERROR_MESSAGES.invalidSeedPayload },
66+
{ status: 400 },
67+
);
68+
}
69+
70+
await upsertReview({
71+
userId: user.id,
72+
bookId: book.id,
73+
rating: parseReviewRating(parsedBody.data.rating),
74+
title: parseReviewTitle(parsedBody.data.title),
75+
body: parseReviewBody(parsedBody.data.body),
76+
});
77+
78+
return NextResponse.json({ ok: true });
79+
}

app/api/test/shelves/route.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { z } from "zod";
3+
4+
import { findUserByProviderIdentity } from "@/lib/auth/users";
5+
import { findBookByGoogleVolumeId } from "@/lib/books/repository";
6+
import { addBookToShelf } from "@/lib/shelves/repository";
7+
import {
8+
E2E_USER_PROVIDER,
9+
isE2EBypassEnabled,
10+
} from "@/lib/test-harness/auth";
11+
import {
12+
TEST_ROUTE_ERROR_MESSAGES,
13+
TEST_USER_KEYS,
14+
} from "@/lib/test-harness/constants";
15+
import {
16+
TEST_BOOK_VOLUME_ID,
17+
seedTestUsers,
18+
} from "@/lib/test-harness/fixtures";
19+
20+
export const runtime = "nodejs";
21+
22+
const seedShelfBookSchema = z.object({
23+
kind: z.literal("add-book"),
24+
shelfId: z.string().min(1),
25+
user: z.enum(TEST_USER_KEYS),
26+
googleVolumeId: z.string().min(1).default(TEST_BOOK_VOLUME_ID),
27+
});
28+
29+
export async function POST(request: NextRequest) {
30+
if (!isE2EBypassEnabled()) {
31+
return NextResponse.json(
32+
{ error: TEST_ROUTE_ERROR_MESSAGES.notAvailable },
33+
{ status: 404 },
34+
);
35+
}
36+
37+
const parsedBody = seedShelfBookSchema.safeParse(
38+
await request.json().catch(() => null),
39+
);
40+
if (!parsedBody.success) {
41+
return NextResponse.json(
42+
{ error: TEST_ROUTE_ERROR_MESSAGES.invalidSeedPayload },
43+
{ status: 400 },
44+
);
45+
}
46+
47+
await seedTestUsers();
48+
const user = await findUserByProviderIdentity(
49+
E2E_USER_PROVIDER,
50+
parsedBody.data.user,
51+
);
52+
if (!user) {
53+
return NextResponse.json(
54+
{ error: TEST_ROUTE_ERROR_MESSAGES.unknownTestUser },
55+
{ status: 400 },
56+
);
57+
}
58+
59+
const book = await findBookByGoogleVolumeId(parsedBody.data.googleVolumeId);
60+
if (!book) {
61+
return NextResponse.json(
62+
{ error: TEST_ROUTE_ERROR_MESSAGES.invalidSeedPayload },
63+
{ status: 400 },
64+
);
65+
}
66+
67+
await addBookToShelf({
68+
shelfId: parsedBody.data.shelfId,
69+
bookId: book.id,
70+
addedById: user.id,
71+
});
72+
73+
return NextResponse.json({ ok: true });
74+
}

0 commit comments

Comments
 (0)