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
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}