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