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 main 204 lines 8.9 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, themes, themePolicies, themePolicyAvailableThemes } 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(themePolicyAvailableThemes).catch(() => {}); 96 await db.delete(themePolicies).catch(() => {}); // cascades to theme_policy_available_themes 97 await db.delete(themes).catch(() => {}); 98 await db.delete(forums).catch(() => {}); 99 return; 100 } 101 102 // Postgres: delete by test DID patterns 103 await db.delete(posts).where(eq(posts.did, config.forumDid)).catch(() => {}); 104 await db.delete(posts).where(like(posts.did, "did:plc:test-%")).catch(() => {}); 105 await db.delete(memberships).where(like(memberships.did, "did:plc:test-%")).catch(() => {}); 106 await db.delete(users).where(like(users.did, "did:plc:test-%")).catch(() => {}); 107 await db.delete(users).where(like(users.did, "did:plc:mod-%")).catch(() => {}); 108 await db.delete(users).where(like(users.did, "did:plc:subject-%")).catch(() => {}); 109 await db.delete(boards).where(eq(boards.did, config.forumDid)).catch(() => {}); 110 await db.delete(categories).where(eq(categories.did, config.forumDid)).catch(() => {}); 111 await db.delete(roles).where(eq(roles.did, config.forumDid)).catch(() => {}); // cascades to role_permissions 112 await db.delete(modActions).where(eq(modActions.did, config.forumDid)).catch(() => {}); 113 await db.delete(backfillErrors).catch(() => {}); 114 await db.delete(backfillProgress).catch(() => {}); 115 // Deleting themePolicies cascades to theme_policy_available_themes 116 await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)).catch(() => {}); 117 await db.delete(themes).where(eq(themes.did, config.forumDid)).catch(() => {}); 118 await db.delete(forums).where(eq(forums.did, config.forumDid)).catch(() => {}); 119 }; 120 121 // Clean database before creating test data to ensure clean state 122 await cleanDatabase(); 123 124 // Insert test forum unless emptyDb is true 125 // No need for onConflictDoNothing since cleanDatabase ensures clean state 126 if (!options.emptyDb) { 127 await db.insert(forums).values({ 128 did: config.forumDid, 129 rkey: "self", 130 cid: "bafytest", 131 name: "Test Forum", 132 description: "A test forum", 133 indexedAt: new Date(), 134 }); 135 } 136 137 const logger = createLogger({ 138 service: "atbb-appview-test", 139 level: "warn", 140 }); 141 142 return { 143 db, 144 config, 145 logger, 146 firehose: stubFirehose, 147 oauthClient: stubOAuthClient, 148 oauthStateStore: stubOAuthStateStore, 149 oauthSessionStore: stubOAuthSessionStore, 150 cookieSessionStore: stubCookieSessionStore, 151 forumAgent: stubForumAgent, 152 backfillManager: null, 153 cleanDatabase, 154 cleanup: async () => { 155 // Clean up test data (order matters due to FKs: posts -> memberships -> users -> boards -> categories -> forums) 156 // Delete all test-specific DIDs (including dynamically generated ones) 157 const testDidPattern = or( 158 eq(posts.did, "did:plc:test-user"), 159 eq(posts.did, "did:plc:topicsuser"), 160 like(posts.did, "did:plc:test-%"), 161 like(posts.did, "did:plc:duptest-%"), 162 like(posts.did, "did:plc:create-%"), 163 like(posts.did, "did:plc:pds-fail-%") 164 ); 165 await db.delete(posts).where(testDidPattern); 166 167 const testMembershipPattern = or( 168 eq(memberships.did, "did:plc:test-user"), 169 eq(memberships.did, "did:plc:topicsuser"), 170 like(memberships.did, "did:plc:test-%"), 171 like(memberships.did, "did:plc:duptest-%"), 172 like(memberships.did, "did:plc:create-%"), 173 like(memberships.did, "did:plc:pds-fail-%") 174 ); 175 await db.delete(memberships).where(testMembershipPattern); 176 177 const testUserPattern = or( 178 eq(users.did, "did:plc:test-user"), 179 eq(users.did, "did:plc:topicsuser"), 180 like(users.did, "did:plc:test-%"), 181 like(users.did, "did:plc:duptest-%"), 182 like(users.did, "did:plc:create-%"), 183 like(users.did, "did:plc:pds-fail-%") 184 ); 185 await db.delete(users).where(testUserPattern); 186 187 await db.delete(boards).where(eq(boards.did, config.forumDid)); 188 await db.delete(categories).where(eq(categories.did, config.forumDid)); 189 await db.delete(roles).where(eq(roles.did, config.forumDid)); // cascades to role_permissions 190 await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)); 191 await db.delete(themes).where(eq(themes.did, config.forumDid)); 192 await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 193 await db.delete(backfillErrors).catch(() => {}); 194 await db.delete(backfillProgress).catch(() => {}); 195 await db.delete(forums).where(eq(forums.did, config.forumDid)); 196 // Close the postgres.js connection pool to prevent connection exhaustion. 197 // With many tests each calling createTestContext(), every call opens a new 198 // pool. Without end(), the pool stays open and PostgreSQL hits max_connections. 199 if (isPostgres) { 200 await (db as any).$client?.end?.(); 201 } 202 }, 203 } as TestContext; 204}