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 root/atb-56-theme-caching-layer 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}