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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at docs/plan-reorganization 189 lines 7.8 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(boards).where(eq(boards.did, config.forumDid)).catch(() => {}); 105 await db.delete(categories).where(eq(categories.did, config.forumDid)).catch(() => {}); 106 await db.delete(roles).where(eq(roles.did, config.forumDid)).catch(() => {}); // cascades to role_permissions 107 await db.delete(modActions).where(eq(modActions.did, config.forumDid)).catch(() => {}); 108 await db.delete(backfillErrors).catch(() => {}); 109 await db.delete(backfillProgress).catch(() => {}); 110 await db.delete(forums).where(eq(forums.did, config.forumDid)).catch(() => {}); 111 }; 112 113 // Clean database before creating test data to ensure clean state 114 await cleanDatabase(); 115 116 // Insert test forum unless emptyDb is true 117 // No need for onConflictDoNothing since cleanDatabase ensures clean state 118 if (!options.emptyDb) { 119 await db.insert(forums).values({ 120 did: config.forumDid, 121 rkey: "self", 122 cid: "bafytest", 123 name: "Test Forum", 124 description: "A test forum", 125 indexedAt: new Date(), 126 }); 127 } 128 129 const logger = createLogger({ 130 service: "atbb-appview-test", 131 level: "warn", 132 }); 133 134 return { 135 db, 136 config, 137 logger, 138 firehose: stubFirehose, 139 oauthClient: stubOAuthClient, 140 oauthStateStore: stubOAuthStateStore, 141 oauthSessionStore: stubOAuthSessionStore, 142 cookieSessionStore: stubCookieSessionStore, 143 forumAgent: stubForumAgent, 144 backfillManager: null, 145 cleanDatabase, 146 cleanup: async () => { 147 // Clean up test data (order matters due to FKs: posts -> memberships -> users -> boards -> categories -> forums) 148 // Delete all test-specific DIDs (including dynamically generated ones) 149 const testDidPattern = or( 150 eq(posts.did, "did:plc:test-user"), 151 eq(posts.did, "did:plc:topicsuser"), 152 like(posts.did, "did:plc:test-%"), 153 like(posts.did, "did:plc:duptest-%"), 154 like(posts.did, "did:plc:create-%"), 155 like(posts.did, "did:plc:pds-fail-%") 156 ); 157 await db.delete(posts).where(testDidPattern); 158 159 const testMembershipPattern = or( 160 eq(memberships.did, "did:plc:test-user"), 161 eq(memberships.did, "did:plc:topicsuser"), 162 like(memberships.did, "did:plc:test-%"), 163 like(memberships.did, "did:plc:duptest-%"), 164 like(memberships.did, "did:plc:create-%"), 165 like(memberships.did, "did:plc:pds-fail-%") 166 ); 167 await db.delete(memberships).where(testMembershipPattern); 168 169 const testUserPattern = or( 170 eq(users.did, "did:plc:test-user"), 171 eq(users.did, "did:plc:topicsuser"), 172 like(users.did, "did:plc:test-%"), 173 like(users.did, "did:plc:duptest-%"), 174 like(users.did, "did:plc:create-%"), 175 like(users.did, "did:plc:pds-fail-%") 176 ); 177 await db.delete(users).where(testUserPattern); 178 179 await db.delete(boards).where(eq(boards.did, config.forumDid)); 180 await db.delete(categories).where(eq(categories.did, config.forumDid)); 181 await db.delete(roles).where(eq(roles.did, config.forumDid)); // cascades to role_permissions 182 await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 183 await db.delete(backfillErrors).catch(() => {}); 184 await db.delete(backfillProgress).catch(() => {}); 185 await db.delete(forums).where(eq(forums.did, config.forumDid)); 186 // No sql.end() needed — createDb owns the client lifecycle 187 }, 188 } as TestContext; 189}