diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b7add95..7db6b55 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -30,11 +30,24 @@ jobs: run: bun run build e2e: - if: false # Disabled: E2E requires Postgres (Phase 14) — SQLite schema diverged at v2.0 needs: ci runs-on: docker container: 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: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 89eb4d3..53675b6 100644 --- a/.gitignore +++ b/.gitignore @@ -228,6 +228,7 @@ uploads/* # Playwright e2e/test.db +e2e/pgdata test-results/ playwright-report/ diff --git a/e2e/seed.ts b/e2e/seed.ts index 4ebbcdf..8085b8c 100644 --- a/e2e/seed.ts +++ b/e2e/seed.ts @@ -1,224 +1,228 @@ -import { Database } from "bun:sqlite"; -import { unlink } from "node:fs/promises"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; 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() { - // Remove old test DB if it exists - try { - await unlink(DB_PATH); - } catch { - // File doesn't exist, that's fine + const client = postgres(DATABASE_URL, { max: 1 }); + const db = drizzle(client, { schema }); + + // Run migrations + 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); - sqlite.run("PRAGMA journal_mode = WAL"); - sqlite.run("PRAGMA foreign_keys = ON"); - - const db = drizzle(sqlite, { schema }); - migrate(db, { migrationsFolder: "./drizzle" }); + // ── User ── + const [user] = await db + .insert(schema.users) + .values({ logtoSub: "e2e-test-user" }) + .returning(); + const userId = user.id; // ── Categories ── - const [uncategorized] = db + const [uncategorized] = await db .insert(schema.categories) - .values({ name: "Uncategorized", icon: "package" }) - .returning() - .all(); + .values({ name: "Uncategorized", icon: "package", userId }) + .returning(); - const [shelter] = db + const [shelter] = await db .insert(schema.categories) - .values({ name: "Shelter", icon: "tent" }) - .returning() - .all(); + .values({ name: "Shelter", icon: "tent", userId }) + .returning(); - const [sleep] = db + const [sleep] = await db .insert(schema.categories) - .values({ name: "Sleep System", icon: "moon" }) - .returning() - .all(); + .values({ name: "Sleep System", icon: "moon", userId }) + .returning(); - const [cook] = db + const [cook] = await db .insert(schema.categories) - .values({ name: "Cook Kit", icon: "flame" }) - .returning() - .all(); + .values({ name: "Cook Kit", icon: "flame", userId }) + .returning(); // ── Items ── - const tent = db + const [tent] = await db .insert(schema.items) .values({ name: "Zpacks Duplex", weightGrams: 539, priceCents: 67900, categoryId: shelter.id, + userId, notes: "DCF shelter, 2-person", }) - .returning() - .get(); + .returning(); - db.insert(schema.items) - .values({ - name: "Borah Gear Tarp", - weightGrams: 156, - priceCents: 11000, - categoryId: shelter.id, - }) - .run(); + await db.insert(schema.items).values({ + name: "Borah Gear Tarp", + weightGrams: 156, + priceCents: 11000, + categoryId: shelter.id, + userId, + }); - const quilt = db + const [quilt] = await db .insert(schema.items) .values({ name: "Enlightened Equipment Enigma 20", weightGrams: 595, priceCents: 34000, categoryId: sleep.id, + userId, notes: "20F quilt", }) - .returning() - .get(); + .returning(); - const pad = db + const [pad] = await db .insert(schema.items) .values({ name: "Therm-a-Rest NeoAir XLite", weightGrams: 354, priceCents: 20999, categoryId: sleep.id, + userId, }) - .returning() - .get(); + .returning(); - const stove = db + const [stove] = await db .insert(schema.items) .values({ name: "BRS-3000T Stove", weightGrams: 25, priceCents: 2000, categoryId: cook.id, + userId, }) - .returning() - .get(); + .returning(); - db.insert(schema.items) - .values({ - name: "Toaks 750ml Pot", - weightGrams: 103, - priceCents: 3000, - categoryId: cook.id, - }) - .run(); + await db.insert(schema.items).values({ + name: "Toaks 750ml Pot", + weightGrams: 103, + priceCents: 3000, + categoryId: cook.id, + userId, + }); // ── Active Thread with 3 Candidates ── - const activeThread = db + const [activeThread] = await db .insert(schema.threads) .values({ name: "New Backpack", status: "active", categoryId: uncategorized.id, + userId, }) - .returning() - .get(); + .returning(); - db.insert(schema.threadCandidates) - .values({ - threadId: activeThread.id, - name: "ULA Circuit", - weightGrams: 1077, - priceCents: 27500, - categoryId: uncategorized.id, - pros: "Great hip belt\nLarge capacity", - cons: "Heavier than competitors", - sortOrder: 1000, - status: "researching", - }) - .run(); + await db.insert(schema.threadCandidates).values({ + threadId: activeThread.id, + name: "ULA Circuit", + weightGrams: 1077, + priceCents: 27500, + categoryId: uncategorized.id, + pros: "Great hip belt\nLarge capacity", + cons: "Heavier than competitors", + sortOrder: 1000, + status: "researching", + }); - db.insert(schema.threadCandidates) - .values({ - threadId: activeThread.id, - name: "Gossamer Gear Mariposa", - weightGrams: 737, - priceCents: 28500, - categoryId: uncategorized.id, - pros: "Very lightweight\nGood ventilation", - cons: "Smaller hip belt pockets", - sortOrder: 2000, - status: "researching", - }) - .run(); + await db.insert(schema.threadCandidates).values({ + threadId: activeThread.id, + name: "Gossamer Gear Mariposa", + weightGrams: 737, + priceCents: 28500, + categoryId: uncategorized.id, + pros: "Very lightweight\nGood ventilation", + cons: "Smaller hip belt pockets", + sortOrder: 2000, + status: "researching", + }); - db.insert(schema.threadCandidates) - .values({ - threadId: activeThread.id, - name: "Granite Gear Crown2 38", - weightGrams: 850, - priceCents: 18000, - categoryId: uncategorized.id, - sortOrder: 3000, - status: "ordered", - }) - .run(); + await db.insert(schema.threadCandidates).values({ + threadId: activeThread.id, + name: "Granite Gear Crown2 38", + weightGrams: 850, + priceCents: 18000, + categoryId: uncategorized.id, + sortOrder: 3000, + status: "ordered", + }); // ── Resolved Thread ── - const resolvedThread = db + const [resolvedThread] = await db .insert(schema.threads) .values({ name: "Camp Stove", status: "resolved", categoryId: cook.id, + userId, resolvedCandidateId: 1, }) - .returning() - .get(); + .returning(); - db.insert(schema.threadCandidates) - .values({ - threadId: resolvedThread.id, - name: "BRS-3000T", - weightGrams: 25, - priceCents: 2000, - categoryId: cook.id, - sortOrder: 1000, - status: "arrived", - }) - .run(); + await db.insert(schema.threadCandidates).values({ + threadId: resolvedThread.id, + name: "BRS-3000T", + weightGrams: 25, + priceCents: 2000, + categoryId: cook.id, + sortOrder: 1000, + status: "arrived", + }); // ── Setup with Items ── - const setup = db + const [setup] = await db .insert(schema.setups) - .values({ name: "Weekend Overnighter" }) - .returning() - .get(); + .values({ name: "Weekend Overnighter", userId }) + .returning(); - db.insert(schema.setupItems) - .values([ - { setupId: setup.id, itemId: tent.id, classification: "base" }, - { setupId: setup.id, itemId: quilt.id, classification: "base" }, - { setupId: setup.id, itemId: pad.id, classification: "base" }, - { setupId: setup.id, itemId: stove.id, classification: "consumable" }, - ]) - .run(); + await db.insert(schema.setupItems).values([ + { setupId: setup.id, itemId: tent.id, classification: "base" }, + { setupId: setup.id, itemId: quilt.id, classification: "base" }, + { setupId: setup.id, itemId: pad.id, classification: "base" }, + { setupId: setup.id, itemId: stove.id, classification: "consumable" }, + ]); // ── API Key for E2E Authentication ── const rawKey = "e2e-test-api-key-for-gearbox-testing"; const keyHash = await Bun.password.hash(rawKey); const keyPrefix = rawKey.slice(0, 8); - db.insert(schema.apiKeys) - .values({ name: "E2E Test Key", keyHash, keyPrefix }) - .run(); + await db + .insert(schema.apiKeys) + .values({ name: "E2E Test Key", keyHash, keyPrefix, userId }); // ── Settings ── - db.insert(schema.settings) - .values([ - { key: "weightUnit", value: "g" }, - { key: "currency", value: "USD" }, - { key: "onboardingComplete", value: "true" }, - ]) - .run(); + await db.insert(schema.settings).values([ + { key: "weightUnit", value: "g", userId }, + { key: "currency", value: "USD", userId }, + { key: "onboardingComplete", value: "true", userId }, + ]); - sqlite.close(); - console.log("E2E test database seeded at", DB_PATH); + await client.end(); + console.log("E2E test database seeded via", DATABASE_URL); } diff --git a/e2e/start-test-server.sh b/e2e/start-test-server.sh index 15650da..c859c51 100755 --- a/e2e/start-test-server.sh +++ b/e2e/start-test-server.sh @@ -1,4 +1,4 @@ #!/bin/sh # Seed the test database, then start the production server 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