WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at atb-52-css-token-extraction 196 lines 8.2 kB view raw
1import { eq, or, like } from "drizzle-orm"; 2import { createDb, runSqliteMigrations } from "@atbb/db"; 3import { forums, posts, users, categories, memberships, boards, roles, modActions, backfillProgress, backfillErrors } from "@atbb/db"; 4import { createLogger } from "@atbb/logger"; 5import path from "path"; 6import { fileURLToPath } from "url"; 7import type { AppConfig } from "../config.js"; 8import type { AppContext } from "../app-context.js"; 9 10const __dirname = fileURLToPath(new URL(".", import.meta.url)); 11 12export interface TestContext extends AppContext { 13 cleanup: () => Promise<void>; 14 cleanDatabase: () => Promise<void>; 15} 16 17export interface TestContextOptions { 18 emptyDb?: boolean; 19} 20 21/** 22 * Create test context with database and sample data. 23 * Call cleanup() after tests to remove test data. 24 * Supports both Postgres (DATABASE_URL=postgres://...) and SQLite (DATABASE_URL=file::memory:). 25 * 26 * SQLite note: Uses file::memory:?cache=shared so that @libsql/client's transaction() 27 * handoff (which sets #db = null and lazily recreates the connection) reconnects to the 28 * same shared in-memory database rather than creating a new empty one. Without 29 * cache=shared, migrations are lost after the first transaction. 30 */ 31export async function createTestContext( 32 options: TestContextOptions = {} 33): Promise<TestContext> { 34 const rawDatabaseUrl = process.env.DATABASE_URL ?? ""; 35 const isPostgres = rawDatabaseUrl.startsWith("postgres"); 36 37 // For SQLite in-memory databases: upgrade to cache=shared so that @libsql/client's 38 // transaction() pattern (which sets #db=null and lazily recreates the connection) 39 // reconnects to the same database rather than creating a new empty in-memory DB. 40 const databaseUrl = 41 rawDatabaseUrl === "file::memory:" || rawDatabaseUrl === ":memory:" 42 ? "file::memory:?cache=shared" 43 : rawDatabaseUrl; 44 45 const config: AppConfig = { 46 port: 3000, 47 forumDid: "did:plc:test-forum", 48 pdsUrl: "https://test.pds", 49 databaseUrl, 50 jetstreamUrl: "wss://test.jetstream", 51 logLevel: "warn", 52 oauthPublicUrl: "http://localhost:3000", 53 sessionSecret: "test-secret-at-least-32-characters-long", 54 sessionTtlDays: 7, 55 backfillRateLimit: 10, 56 backfillConcurrency: 10, 57 backfillCursorMaxAgeHours: 48, 58 }; 59 60 const db = createDb(config.databaseUrl); 61 const isSqlite = !isPostgres; 62 63 // For SQLite: run migrations programmatically before any tests. 64 // Uses runSqliteMigrations from @atbb/db to ensure the same drizzle-orm instance 65 // is used for both database creation and migration (avoids cross-package module issues). 66 if (isSqlite) { 67 const migrationsFolder = path.resolve(__dirname, "../../../drizzle-sqlite"); 68 await runSqliteMigrations(db, migrationsFolder); 69 } 70 71 // Create stub OAuth dependencies (unused in read-path tests) 72 const stubFirehose = { 73 start: () => Promise.resolve(), 74 stop: () => Promise.resolve(), 75 } as any; 76 77 const stubOAuthClient = {} as any; 78 const stubOAuthStateStore = { destroy: () => {} } as any; 79 const stubOAuthSessionStore = { destroy: () => {} } as any; 80 const stubCookieSessionStore = { destroy: () => {} } as any; 81 const stubForumAgent = null; // Mock ForumAgent is null by default (can be overridden in tests) 82 83 const cleanDatabase = async () => { 84 if (isSqlite) { 85 // SQLite in-memory: delete all rows in FK order (role_permissions cascade from roles) 86 await db.delete(posts).catch(() => {}); 87 await db.delete(memberships).catch(() => {}); 88 await db.delete(users).catch(() => {}); 89 await db.delete(boards).catch(() => {}); 90 await db.delete(categories).catch(() => {}); 91 await db.delete(roles).catch(() => {}); // cascades to role_permissions 92 await db.delete(modActions).catch(() => {}); 93 await db.delete(backfillErrors).catch(() => {}); 94 await db.delete(backfillProgress).catch(() => {}); 95 await db.delete(forums).catch(() => {}); 96 return; 97 } 98 99 // Postgres: delete by test DID patterns 100 await db.delete(posts).where(eq(posts.did, config.forumDid)).catch(() => {}); 101 await db.delete(posts).where(like(posts.did, "did:plc:test-%")).catch(() => {}); 102 await db.delete(memberships).where(like(memberships.did, "did:plc:test-%")).catch(() => {}); 103 await db.delete(users).where(like(users.did, "did:plc:test-%")).catch(() => {}); 104 await db.delete(users).where(like(users.did, "did:plc:mod-%")).catch(() => {}); 105 await db.delete(users).where(like(users.did, "did:plc:subject-%")).catch(() => {}); 106 await db.delete(boards).where(eq(boards.did, config.forumDid)).catch(() => {}); 107 await db.delete(categories).where(eq(categories.did, config.forumDid)).catch(() => {}); 108 await db.delete(roles).where(eq(roles.did, config.forumDid)).catch(() => {}); // cascades to role_permissions 109 await db.delete(modActions).where(eq(modActions.did, config.forumDid)).catch(() => {}); 110 await db.delete(backfillErrors).catch(() => {}); 111 await db.delete(backfillProgress).catch(() => {}); 112 await db.delete(forums).where(eq(forums.did, config.forumDid)).catch(() => {}); 113 }; 114 115 // Clean database before creating test data to ensure clean state 116 await cleanDatabase(); 117 118 // Insert test forum unless emptyDb is true 119 // No need for onConflictDoNothing since cleanDatabase ensures clean state 120 if (!options.emptyDb) { 121 await db.insert(forums).values({ 122 did: config.forumDid, 123 rkey: "self", 124 cid: "bafytest", 125 name: "Test Forum", 126 description: "A test forum", 127 indexedAt: new Date(), 128 }); 129 } 130 131 const logger = createLogger({ 132 service: "atbb-appview-test", 133 level: "warn", 134 }); 135 136 return { 137 db, 138 config, 139 logger, 140 firehose: stubFirehose, 141 oauthClient: stubOAuthClient, 142 oauthStateStore: stubOAuthStateStore, 143 oauthSessionStore: stubOAuthSessionStore, 144 cookieSessionStore: stubCookieSessionStore, 145 forumAgent: stubForumAgent, 146 backfillManager: null, 147 cleanDatabase, 148 cleanup: async () => { 149 // Clean up test data (order matters due to FKs: posts -> memberships -> users -> boards -> categories -> forums) 150 // Delete all test-specific DIDs (including dynamically generated ones) 151 const testDidPattern = or( 152 eq(posts.did, "did:plc:test-user"), 153 eq(posts.did, "did:plc:topicsuser"), 154 like(posts.did, "did:plc:test-%"), 155 like(posts.did, "did:plc:duptest-%"), 156 like(posts.did, "did:plc:create-%"), 157 like(posts.did, "did:plc:pds-fail-%") 158 ); 159 await db.delete(posts).where(testDidPattern); 160 161 const testMembershipPattern = or( 162 eq(memberships.did, "did:plc:test-user"), 163 eq(memberships.did, "did:plc:topicsuser"), 164 like(memberships.did, "did:plc:test-%"), 165 like(memberships.did, "did:plc:duptest-%"), 166 like(memberships.did, "did:plc:create-%"), 167 like(memberships.did, "did:plc:pds-fail-%") 168 ); 169 await db.delete(memberships).where(testMembershipPattern); 170 171 const testUserPattern = or( 172 eq(users.did, "did:plc:test-user"), 173 eq(users.did, "did:plc:topicsuser"), 174 like(users.did, "did:plc:test-%"), 175 like(users.did, "did:plc:duptest-%"), 176 like(users.did, "did:plc:create-%"), 177 like(users.did, "did:plc:pds-fail-%") 178 ); 179 await db.delete(users).where(testUserPattern); 180 181 await db.delete(boards).where(eq(boards.did, config.forumDid)); 182 await db.delete(categories).where(eq(categories.did, config.forumDid)); 183 await db.delete(roles).where(eq(roles.did, config.forumDid)); // cascades to role_permissions 184 await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 185 await db.delete(backfillErrors).catch(() => {}); 186 await db.delete(backfillProgress).catch(() => {}); 187 await db.delete(forums).where(eq(forums.did, config.forumDid)); 188 // Close the postgres.js connection pool to prevent connection exhaustion. 189 // With many tests each calling createTestContext(), every call opens a new 190 // pool. Without end(), the pool stays open and PostgreSQL hits max_connections. 191 if (isPostgres) { 192 await (db as any).$client?.end?.(); 193 } 194 }, 195 } as TestContext; 196}