Skip to content

Commit 0d7d7cf

Browse files
committed
.
1 parent 1588c16 commit 0d7d7cf

14 files changed

Lines changed: 277 additions & 227 deletions

File tree

.claude/settings.local.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"Bash(bun run build *)",
2424
"Bash(bun typecheck *)",
2525
"Bash(bun run typecheck *)",
26+
"Bash(bun run typecheck:e2e *)",
2627
"Bash(bun lint *)",
2728
"Bash(bun run lint *)",
2829
"Bash(bun format *)",

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ The codebase uses Docker to run third-party services locally (Minio for a local
7070
- `bun format` will format the codebase with Prettier
7171
- `bun typecheck` will typecheck the codebase, and `bun lint` will lint it
7272
- `bun run test:unit` runs the pure unit tests (`*.unit.test.ts`)
73-
- `bun run test:integration` runs the integration suite. It needs no live services: the database is an ephemeral in-memory PGlite (started by `tools/test.sh`, with the schema loaded from `supabase/config.toml`'s `schema_paths`), and Supabase, S3, and Axiom are faked (see `.env.test`). Do not run `bun test` (Bun's built-in runner); it's intercepted with a pointer to these scripts.
73+
- `bun run test:integration` runs the integration suite. It needs no live services: the database is an ephemeral in-memory PGlite (started by `tools/test.sh`, with the real `supabase/migrations` applied by `tools/load_schema.ts` so it matches production), and Supabase, S3, and Axiom are faked (see `.env.test`). Do not run `bun test` (Bun's built-in runner); it's intercepted with a pointer to these scripts.
7474

7575
# How you should work
7676

e2e/helpers/seed.ts

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,62 @@
1-
import { Pool } from 'pg';
2-
import { IdDomain, generateId } from '../../src/services/db/id_gen';
1+
import { Page } from '@playwright/test';
2+
import { buildMapZip } from '../../src/services/maps/tests/map_generator';
33

4-
// Direct Postgres access to the running Supabase DB (connection comes from .env.e2e, layered in by
5-
// tools/e2e.sh). Used to seed many maps quickly: uploading 20+ through the real submit flow would
6-
// be far too slow just to populate a list for the pagination test.
7-
let pool: Pool | undefined;
8-
function getPool(): Pool {
9-
if (pool == null) {
10-
pool = new Pool({
11-
host: process.env.PGHOST,
12-
port: Number(process.env.PGPORT),
13-
user: process.env.PGUSER,
14-
database: process.env.PGDATABASE,
15-
password: process.env.PGPASSWORD,
16-
});
17-
}
18-
return pool;
19-
}
4+
// Seeds maps through the real submit flow over HTTP (presigned PUT to S3 + server-side validation +
5+
// publish), rather than writing rows directly: it's slower than a raw insert but goes through the
6+
// exact code path production does, so it can't drift from the schema the way hand-written SQL can.
7+
// Uses `page.request` so it shares the (authenticated) browser context's cookies; the caller must
8+
// have logged the page in first.
209

2110
export type SeededMap = { id: string; title: string };
2211

2312
/**
24-
* Inserts `count` public, valid maps that all share one (caller-supplied, run-unique) artist, so a
25-
* search for that artist isolates exactly this run's maps from anything else in the persisted DB.
13+
* Uploads `count` valid maps, all under one (caller-supplied, run-unique) artist so a search for it
14+
* isolates exactly this run's maps. Returns the published map ids.
2615
*/
27-
export async function seedPublicMaps(opts: {
28-
artist: string;
29-
count: number;
30-
}): Promise<SeededMap[]> {
16+
export async function seedPublicMaps(
17+
page: Page,
18+
opts: { artist: string; count: number }
19+
): Promise<SeededMap[]> {
3120
const { artist, count } = opts;
3221
const maps: SeededMap[] = [];
3322
for (let i = 0; i < count; i++) {
34-
const title = `Infinite Scroll Map ${String(i).padStart(3, '0')}`;
35-
const id = await generateId(
36-
IdDomain.MAPS,
37-
async (candidate) =>
38-
((await getPool().query('SELECT 1 FROM maps WHERE id = $1', [candidate])).rowCount ?? 0) > 0
39-
);
40-
if (id == null) {
41-
throw new Error('Could not generate a unique map id for seeding');
23+
const n = String(i).padStart(3, '0');
24+
const title = `Infinite Scroll Map ${n}`;
25+
const zip = buildMapZip({ folder: `IScroll${n}`, title, artist });
26+
27+
// 1. Reserve a map id + presigned S3 upload URL.
28+
const submit = await page.request.post('/api/maps/submit', { data: { title: `${title}.zip` } });
29+
const submitBody = await submit.json();
30+
if (!submitBody.success) {
31+
throw new Error(`submit failed for "${title}": ${submitBody.errorMessage}`);
32+
}
33+
const { id, url } = submitBody as { id: string; url: string };
34+
35+
// 2. Upload the archive to the presigned URL (real S3 / Minio).
36+
const put = await page.request.put(url, {
37+
data: zip,
38+
headers: { 'Content-Type': 'application/zip' },
39+
});
40+
if (!put.ok()) {
41+
throw new Error(`S3 upload failed for "${title}": ${put.status()}`);
42+
}
43+
44+
// 3. Validate + publish.
45+
const complete = await page.request.post('/api/maps/submit/complete', {
46+
data: { id, isReupload: false },
47+
});
48+
const completeBody = await complete.json();
49+
if (!completeBody.success) {
50+
throw new Error(`complete failed for "${title}": ${completeBody.errorMessage}`);
4251
}
43-
await getPool().query(
44-
`INSERT INTO maps
45-
(id, visibility, validity, submission_date, title, artist, uploader, download_count, complexity)
46-
VALUES ($1, 'public', 'valid', $2, $3, $4, 'e2e', 0, 1)`,
47-
[id, new Date(Date.now() + i * 1000).toISOString(), title, artist]
48-
);
4952
maps.push({ id, title });
5053
}
5154
return maps;
5255
}
5356

54-
export async function deleteSeededMaps(artist: string): Promise<void> {
55-
await getPool().query('DELETE FROM maps WHERE artist = $1', [artist]);
56-
}
57-
58-
export async function closeSeedPool(): Promise<void> {
59-
if (pool != null) {
60-
await pool.end();
61-
pool = undefined;
57+
/** Deletes the seeded maps via the real delete endpoint (cascades difficulties/favorites + S3). */
58+
export async function deleteSeededMaps(page: Page, ids: string[]): Promise<void> {
59+
for (const id of ids) {
60+
await page.request.post(`/api/maps/${id}/delete`);
6261
}
6362
}

e2e/infinite_scroll.e2e.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
import { expect, Locator, Page, test } from '@playwright/test';
2-
import { closeSeedPool, deleteSeededMaps, seedPublicMaps } from './helpers/seed';
2+
import { makeUser, signUpAndConfirm } from './helpers/auth';
3+
import { deleteSeededMaps, seedPublicMaps } from './helpers/seed';
34

45
// SEARCH_LIMIT in src/app/map_list_presenter.ts.
56
const PAGE_SIZE = 20;
67
const TOTAL = 25;
78

8-
const seededArtists: string[] = [];
9-
10-
// Seeds TOTAL maps under a process-unique artist so re-runs against the persisted Supabase DB
11-
// never see each other's maps, and each test's set is isolated from the other's.
12-
async function seedRun(): Promise<string> {
13-
const unique = `${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
14-
const artist = `infscroll${unique}`;
15-
seededArtists.push(artist);
16-
await seedPublicMaps({ artist, count: TOTAL });
17-
return artist;
18-
}
9+
// Process-unique artist so re-runs against the persisted Supabase DB never collide; one seeded set
10+
// is shared by both tests.
11+
const unique = `${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
12+
const artist = `infscroll${unique}`;
1913

2014
// One row per map (each row links to /map/{id}); excludes the header and loading skeleton.
2115
const mapRows = (page: Page) => page.locator('tr', { has: page.locator('a[href^="/map/"]') });
@@ -32,7 +26,6 @@ const nextPageResponse = (page: Page) =>
3226
// Opens the list scoped (by the run-unique artist) to exactly this run's maps and asserts the
3327
// first page rendered with "Load more" on offer.
3428
async function openFirstPage(page: Page): Promise<{ rows: Locator; loadMore: Locator }> {
35-
const artist = await seedRun();
3629
const rows = mapRows(page);
3730
await page.goto(`/?q=${artist}`);
3831
await page.waitForResponse(
@@ -89,11 +82,19 @@ async function wheelUntilButtonDisabled(page: Page, loadMore: Locator): Promise<
8982
}
9083

9184
test.describe('home page infinite scroll', () => {
85+
// A dedicated, authenticated page seeds the maps once over the real API; both tests share them.
86+
let seedPage: Page;
87+
let seededIds: string[];
88+
89+
test.beforeAll(async ({ browser }) => {
90+
seedPage = await browser.newPage();
91+
await signUpAndConfirm(seedPage, makeUser('isscroll'));
92+
seededIds = (await seedPublicMaps(seedPage, { artist, count: TOTAL })).map((m) => m.id);
93+
});
94+
9295
test.afterAll(async () => {
93-
for (const artist of seededArtists) {
94-
await deleteSeededMaps(artist);
95-
}
96-
await closeSeedPool();
96+
await deleteSeededMaps(seedPage, seededIds);
97+
await seedPage.close();
9798
});
9899

99100
test('appends the next page when "Load more" is clicked', async ({ page }) => {

src/app/api/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as qs from 'qs';
22
import { ApiResponse } from 'schema/api';
33
import { encodeFilter } from 'schema/map_filter';
44
import {
5+
CompleteUploadRequest,
6+
CompleteUploadResponse,
57
DeleteMapResponse,
68
FindMapsResponse,
79
GetMapResponse,
@@ -34,6 +36,7 @@ export interface Api {
3436
getMap(id: string): Promise<GetMapResponse>;
3537
deleteMap(id: string): Promise<DeleteMapResponse>;
3638
submitMap(req: SubmitMapRequest): Promise<SubmitMapResponse>;
39+
completeMapUpload(req: CompleteUploadRequest): Promise<CompleteUploadResponse>;
3740
}
3841

3942
export class HttpApi implements Api {
@@ -88,6 +91,11 @@ export class HttpApi implements Api {
8891
const resp = await post(path(this.apiBase, 'maps', 'submit'), req);
8992
return SubmitMapResponse.parse(resp);
9093
}
94+
95+
async completeMapUpload(req: CompleteUploadRequest): Promise<CompleteUploadResponse> {
96+
const resp = await post(path(this.apiBase, 'maps', 'submit', 'complete'), req);
97+
return CompleteUploadResponse.parse(resp);
98+
}
9199
}
92100

93101
function path(...parts: string[]) {

src/app/api/fake_api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ApiResponse } from 'schema/api';
22
import {
3+
CompleteUploadRequest,
4+
CompleteUploadResponse,
35
DeleteMapResponse,
46
FindMapsResponse,
57
GetMapResponse,
@@ -75,6 +77,11 @@ export class FakeApi implements Api {
7577
await delay();
7678
return { success: true, id: allStar.id, url: '' };
7779
}
80+
81+
async completeMapUpload(_req: CompleteUploadRequest): Promise<CompleteUploadResponse> {
82+
await delay();
83+
return { success: true, map: allStar };
84+
}
7885
}
7986

8087
const allStar: PDMap = {

src/app/api/maps/submit/complete/actions.ts

Lines changed: 0 additions & 117 deletions
This file was deleted.

0 commit comments

Comments
 (0)