fix: migrate E2E tests from SQLite to Postgres
Some checks failed
CI / ci (push) Successful in 1m0s
CI / e2e (push) Failing after 8m39s

- Rewrite e2e/seed.ts to use postgres driver instead of bun:sqlite
- Add userId to all seeded entities (multi-user schema)
- Add Postgres service container to CI E2E job
- Remove DATABASE_PATH from test server start script
- Re-enable E2E job in CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:56:11 +02:00
parent b769034b45
commit 54614869cf
4 changed files with 158 additions and 140 deletions

View File

@@ -30,11 +30,24 @@ jobs:
run: bun run build run: bun run build
e2e: e2e:
if: false # Disabled: E2E requires Postgres (Phase 14) — SQLite schema diverged at v2.0
needs: ci needs: ci
runs-on: docker runs-on: docker
container: container:
image: mcr.microsoft.com/playwright:v1.59.1-noble image: mcr.microsoft.com/playwright:v1.59.1-noble
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: gearbox
POSTGRES_PASSWORD: gearbox
POSTGRES_DB: gearbox
options: >-
--health-cmd "pg_isready -U gearbox"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://gearbox:gearbox@postgres:5432/gearbox
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

1
.gitignore vendored
View File

@@ -228,6 +228,7 @@ uploads/*
# Playwright # Playwright
e2e/test.db e2e/test.db
e2e/pgdata
test-results/ test-results/
playwright-report/ playwright-report/

View File

@@ -1,224 +1,228 @@
import { Database } from "bun:sqlite"; import { sql } from "drizzle-orm";
import { unlink } from "node:fs/promises"; import { drizzle } from "drizzle-orm/postgres-js";
import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/postgres-js/migrator";
import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import postgres from "postgres";
import * as schema from "../src/db/schema"; import * as schema from "../src/db/schema";
const DB_PATH = "./e2e/test.db"; const DATABASE_URL =
process.env.DATABASE_URL ||
"postgresql://gearbox:gearbox@localhost:5432/gearbox";
export async function seedTestDatabase() { export async function seedTestDatabase() {
// Remove old test DB if it exists const client = postgres(DATABASE_URL, { max: 1 });
try { const db = drizzle(client, { schema });
await unlink(DB_PATH);
} catch { // Run migrations
// File doesn't exist, that's fine await migrate(db, { migrationsFolder: "./drizzle-pg" });
// Clean all tables for a fresh seed
const tables = [
"setup_items",
"setups",
"thread_candidates",
"threads",
"items",
"global_item_tags",
"global_items",
"tags",
"oauth_tokens",
"oauth_codes",
"oauth_clients",
"api_keys",
"settings",
"categories",
"users",
];
for (const t of tables) {
await db.execute(sql.raw(`TRUNCATE TABLE "${t}" RESTART IDENTITY CASCADE`));
} }
const sqlite = new Database(DB_PATH); // ── User ──
sqlite.run("PRAGMA journal_mode = WAL"); const [user] = await db
sqlite.run("PRAGMA foreign_keys = ON"); .insert(schema.users)
.values({ logtoSub: "e2e-test-user" })
const db = drizzle(sqlite, { schema }); .returning();
migrate(db, { migrationsFolder: "./drizzle" }); const userId = user.id;
// ── Categories ── // ── Categories ──
const [uncategorized] = db const [uncategorized] = await db
.insert(schema.categories) .insert(schema.categories)
.values({ name: "Uncategorized", icon: "package" }) .values({ name: "Uncategorized", icon: "package", userId })
.returning() .returning();
.all();
const [shelter] = db const [shelter] = await db
.insert(schema.categories) .insert(schema.categories)
.values({ name: "Shelter", icon: "tent" }) .values({ name: "Shelter", icon: "tent", userId })
.returning() .returning();
.all();
const [sleep] = db const [sleep] = await db
.insert(schema.categories) .insert(schema.categories)
.values({ name: "Sleep System", icon: "moon" }) .values({ name: "Sleep System", icon: "moon", userId })
.returning() .returning();
.all();
const [cook] = db const [cook] = await db
.insert(schema.categories) .insert(schema.categories)
.values({ name: "Cook Kit", icon: "flame" }) .values({ name: "Cook Kit", icon: "flame", userId })
.returning() .returning();
.all();
// ── Items ── // ── Items ──
const tent = db const [tent] = await db
.insert(schema.items) .insert(schema.items)
.values({ .values({
name: "Zpacks Duplex", name: "Zpacks Duplex",
weightGrams: 539, weightGrams: 539,
priceCents: 67900, priceCents: 67900,
categoryId: shelter.id, categoryId: shelter.id,
userId,
notes: "DCF shelter, 2-person", notes: "DCF shelter, 2-person",
}) })
.returning() .returning();
.get();
db.insert(schema.items) await db.insert(schema.items).values({
.values({ name: "Borah Gear Tarp",
name: "Borah Gear Tarp", weightGrams: 156,
weightGrams: 156, priceCents: 11000,
priceCents: 11000, categoryId: shelter.id,
categoryId: shelter.id, userId,
}) });
.run();
const quilt = db const [quilt] = await db
.insert(schema.items) .insert(schema.items)
.values({ .values({
name: "Enlightened Equipment Enigma 20", name: "Enlightened Equipment Enigma 20",
weightGrams: 595, weightGrams: 595,
priceCents: 34000, priceCents: 34000,
categoryId: sleep.id, categoryId: sleep.id,
userId,
notes: "20F quilt", notes: "20F quilt",
}) })
.returning() .returning();
.get();
const pad = db const [pad] = await db
.insert(schema.items) .insert(schema.items)
.values({ .values({
name: "Therm-a-Rest NeoAir XLite", name: "Therm-a-Rest NeoAir XLite",
weightGrams: 354, weightGrams: 354,
priceCents: 20999, priceCents: 20999,
categoryId: sleep.id, categoryId: sleep.id,
userId,
}) })
.returning() .returning();
.get();
const stove = db const [stove] = await db
.insert(schema.items) .insert(schema.items)
.values({ .values({
name: "BRS-3000T Stove", name: "BRS-3000T Stove",
weightGrams: 25, weightGrams: 25,
priceCents: 2000, priceCents: 2000,
categoryId: cook.id, categoryId: cook.id,
userId,
}) })
.returning() .returning();
.get();
db.insert(schema.items) await db.insert(schema.items).values({
.values({ name: "Toaks 750ml Pot",
name: "Toaks 750ml Pot", weightGrams: 103,
weightGrams: 103, priceCents: 3000,
priceCents: 3000, categoryId: cook.id,
categoryId: cook.id, userId,
}) });
.run();
// ── Active Thread with 3 Candidates ── // ── Active Thread with 3 Candidates ──
const activeThread = db const [activeThread] = await db
.insert(schema.threads) .insert(schema.threads)
.values({ .values({
name: "New Backpack", name: "New Backpack",
status: "active", status: "active",
categoryId: uncategorized.id, categoryId: uncategorized.id,
userId,
}) })
.returning() .returning();
.get();
db.insert(schema.threadCandidates) await db.insert(schema.threadCandidates).values({
.values({ threadId: activeThread.id,
threadId: activeThread.id, name: "ULA Circuit",
name: "ULA Circuit", weightGrams: 1077,
weightGrams: 1077, priceCents: 27500,
priceCents: 27500, categoryId: uncategorized.id,
categoryId: uncategorized.id, pros: "Great hip belt\nLarge capacity",
pros: "Great hip belt\nLarge capacity", cons: "Heavier than competitors",
cons: "Heavier than competitors", sortOrder: 1000,
sortOrder: 1000, status: "researching",
status: "researching", });
})
.run();
db.insert(schema.threadCandidates) await db.insert(schema.threadCandidates).values({
.values({ threadId: activeThread.id,
threadId: activeThread.id, name: "Gossamer Gear Mariposa",
name: "Gossamer Gear Mariposa", weightGrams: 737,
weightGrams: 737, priceCents: 28500,
priceCents: 28500, categoryId: uncategorized.id,
categoryId: uncategorized.id, pros: "Very lightweight\nGood ventilation",
pros: "Very lightweight\nGood ventilation", cons: "Smaller hip belt pockets",
cons: "Smaller hip belt pockets", sortOrder: 2000,
sortOrder: 2000, status: "researching",
status: "researching", });
})
.run();
db.insert(schema.threadCandidates) await db.insert(schema.threadCandidates).values({
.values({ threadId: activeThread.id,
threadId: activeThread.id, name: "Granite Gear Crown2 38",
name: "Granite Gear Crown2 38", weightGrams: 850,
weightGrams: 850, priceCents: 18000,
priceCents: 18000, categoryId: uncategorized.id,
categoryId: uncategorized.id, sortOrder: 3000,
sortOrder: 3000, status: "ordered",
status: "ordered", });
})
.run();
// ── Resolved Thread ── // ── Resolved Thread ──
const resolvedThread = db const [resolvedThread] = await db
.insert(schema.threads) .insert(schema.threads)
.values({ .values({
name: "Camp Stove", name: "Camp Stove",
status: "resolved", status: "resolved",
categoryId: cook.id, categoryId: cook.id,
userId,
resolvedCandidateId: 1, resolvedCandidateId: 1,
}) })
.returning() .returning();
.get();
db.insert(schema.threadCandidates) await db.insert(schema.threadCandidates).values({
.values({ threadId: resolvedThread.id,
threadId: resolvedThread.id, name: "BRS-3000T",
name: "BRS-3000T", weightGrams: 25,
weightGrams: 25, priceCents: 2000,
priceCents: 2000, categoryId: cook.id,
categoryId: cook.id, sortOrder: 1000,
sortOrder: 1000, status: "arrived",
status: "arrived", });
})
.run();
// ── Setup with Items ── // ── Setup with Items ──
const setup = db const [setup] = await db
.insert(schema.setups) .insert(schema.setups)
.values({ name: "Weekend Overnighter" }) .values({ name: "Weekend Overnighter", userId })
.returning() .returning();
.get();
db.insert(schema.setupItems) await db.insert(schema.setupItems).values([
.values([ { setupId: setup.id, itemId: tent.id, classification: "base" },
{ setupId: setup.id, itemId: tent.id, classification: "base" }, { setupId: setup.id, itemId: quilt.id, classification: "base" },
{ setupId: setup.id, itemId: quilt.id, classification: "base" }, { setupId: setup.id, itemId: pad.id, classification: "base" },
{ setupId: setup.id, itemId: pad.id, classification: "base" }, { setupId: setup.id, itemId: stove.id, classification: "consumable" },
{ setupId: setup.id, itemId: stove.id, classification: "consumable" }, ]);
])
.run();
// ── API Key for E2E Authentication ── // ── API Key for E2E Authentication ──
const rawKey = "e2e-test-api-key-for-gearbox-testing"; const rawKey = "e2e-test-api-key-for-gearbox-testing";
const keyHash = await Bun.password.hash(rawKey); const keyHash = await Bun.password.hash(rawKey);
const keyPrefix = rawKey.slice(0, 8); const keyPrefix = rawKey.slice(0, 8);
db.insert(schema.apiKeys) await db
.values({ name: "E2E Test Key", keyHash, keyPrefix }) .insert(schema.apiKeys)
.run(); .values({ name: "E2E Test Key", keyHash, keyPrefix, userId });
// ── Settings ── // ── Settings ──
db.insert(schema.settings) await db.insert(schema.settings).values([
.values([ { key: "weightUnit", value: "g", userId },
{ key: "weightUnit", value: "g" }, { key: "currency", value: "USD", userId },
{ key: "currency", value: "USD" }, { key: "onboardingComplete", value: "true", userId },
{ key: "onboardingComplete", value: "true" }, ]);
])
.run();
sqlite.close(); await client.end();
console.log("E2E test database seeded at", DB_PATH); console.log("E2E test database seeded via", DATABASE_URL);
} }

View File

@@ -1,4 +1,4 @@
#!/bin/sh #!/bin/sh
# Seed the test database, then start the production server # Seed the test database, then start the production server
bun run e2e/global-setup.ts bun run e2e/global-setup.ts
NODE_ENV=production DATABASE_PATH=./e2e/test.db bun run src/server/index.ts NODE_ENV=production bun run src/server/index.ts