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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
3// Ensure SESSION_SECRET is always set for all config tests BEFORE capturing originalEnv
4process.env.SESSION_SECRET = "this-is-a-valid-32-char-secret!!";
5
6describe("loadConfig", () => {
7 // Now capture originalEnv AFTER setting SESSION_SECRET
8 const originalEnv = { ...process.env };
9
10 beforeEach(() => {
11 vi.resetModules();
12 });
13
14 afterEach(() => {
15 process.env = { ...originalEnv };
16 });
17
18 async function loadConfig() {
19 const mod = await import("../config.js");
20 return mod.loadConfig();
21 }
22
23 it("returns default port 3000 when PORT is undefined", async () => {
24 delete process.env.PORT;
25 const config = await loadConfig();
26 expect(config.port).toBe(3000);
27 });
28
29 it("parses PORT as an integer", async () => {
30 process.env.PORT = "4000";
31 const config = await loadConfig();
32 expect(config.port).toBe(4000);
33 expect(typeof config.port).toBe("number");
34 });
35
36 it("returns default PDS URL when PDS_URL is undefined", async () => {
37 delete process.env.PDS_URL;
38 const config = await loadConfig();
39 expect(config.pdsUrl).toBe("https://bsky.social");
40 });
41
42 it("uses provided environment variables", async () => {
43 process.env.PORT = "5000";
44 process.env.FORUM_DID = "did:plc:test123";
45 process.env.PDS_URL = "https://my-pds.example.com";
46 process.env.DATABASE_URL = "postgres://localhost/testdb";
47 const config = await loadConfig();
48 expect(config.port).toBe(5000);
49 expect(config.forumDid).toBe("did:plc:test123");
50 expect(config.pdsUrl).toBe("https://my-pds.example.com");
51 expect(config.databaseUrl).toBe("postgres://localhost/testdb");
52 });
53
54 it("returns empty string for forumDid when FORUM_DID is undefined", async () => {
55 delete process.env.FORUM_DID;
56 const config = await loadConfig();
57 expect(config.forumDid).toBe("");
58 });
59
60 it("returns empty string for databaseUrl when DATABASE_URL is undefined", async () => {
61 delete process.env.DATABASE_URL;
62 const config = await loadConfig();
63 expect(config.databaseUrl).toBe("");
64 });
65
66 it("returns NaN for port when PORT is empty string (?? does not catch empty strings)", async () => {
67 process.env.PORT = "";
68 const config = await loadConfig();
69 // Documents a gap: ?? only catches null/undefined, not ""
70 expect(config.port).toBeNaN();
71 });
72
73 describe("OAuth configuration", () => {
74 it("loads OAuth configuration from environment variables", async () => {
75 process.env.OAUTH_PUBLIC_URL = "https://forum.example.com";
76 process.env.SESSION_SECRET = "my-super-secret-key-that-is-32-chars";
77 process.env.SESSION_TTL_DAYS = "14";
78 process.env.REDIS_URL = "redis://localhost:6379";
79
80 const config = await loadConfig();
81
82 expect(config.oauthPublicUrl).toBe("https://forum.example.com");
83 expect(config.sessionSecret).toBe("my-super-secret-key-that-is-32-chars");
84 expect(config.sessionTtlDays).toBe(14);
85 expect(config.redisUrl).toBe("redis://localhost:6379");
86 });
87
88 it("uses default values for optional OAuth config", async () => {
89 delete process.env.OAUTH_PUBLIC_URL;
90 delete process.env.SESSION_TTL_DAYS;
91 delete process.env.REDIS_URL;
92
93 const config = await loadConfig();
94
95 expect(config.oauthPublicUrl).toBe("http://localhost:3000");
96 expect(config.sessionTtlDays).toBe(7);
97 expect(config.redisUrl).toBeUndefined();
98 });
99
100 it("throws error when SESSION_SECRET is missing", async () => {
101 delete process.env.SESSION_SECRET;
102
103 await expect(loadConfig()).rejects.toThrow(
104 "SESSION_SECRET must be at least 32 characters"
105 );
106 });
107
108 it("throws error when SESSION_SECRET is too short", async () => {
109 process.env.SESSION_SECRET = "too-short";
110
111 await expect(loadConfig()).rejects.toThrow(
112 "SESSION_SECRET must be at least 32 characters"
113 );
114 });
115
116 it("accepts SESSION_SECRET with exactly 32 characters", async () => {
117 process.env.SESSION_SECRET = "12345678901234567890123456789012"; // exactly 32 chars
118
119 const config = await loadConfig();
120
121 expect(config.sessionSecret).toBe("12345678901234567890123456789012");
122 });
123
124 it("throws error when OAUTH_PUBLIC_URL is missing in production", async () => {
125 process.env.NODE_ENV = "production";
126 delete process.env.OAUTH_PUBLIC_URL;
127
128 await expect(loadConfig()).rejects.toThrow(
129 "OAUTH_PUBLIC_URL is required in production"
130 );
131 });
132
133 it("allows missing OAUTH_PUBLIC_URL in development", async () => {
134 delete process.env.NODE_ENV;
135 delete process.env.OAUTH_PUBLIC_URL;
136
137 const config = await loadConfig();
138
139 expect(config.oauthPublicUrl).toBe("http://localhost:3000");
140 });
141
142 it("warns about in-memory sessions in production", async () => {
143 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
144 process.env.NODE_ENV = "production";
145 process.env.OAUTH_PUBLIC_URL = "https://example.com";
146 delete process.env.REDIS_URL;
147
148 await loadConfig();
149
150 expect(warnSpy).toHaveBeenCalledWith(
151 expect.stringContaining("in-memory session storage in production")
152 );
153
154 warnSpy.mockRestore();
155 });
156
157 it("does not warn about in-memory sessions when REDIS_URL is set", async () => {
158 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
159 process.env.NODE_ENV = "production";
160 process.env.OAUTH_PUBLIC_URL = "https://example.com";
161 process.env.REDIS_URL = "redis://localhost:6379";
162
163 await loadConfig();
164
165 expect(warnSpy).not.toHaveBeenCalled();
166
167 warnSpy.mockRestore();
168 });
169
170 it("does not warn about in-memory sessions in development", async () => {
171 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
172 delete process.env.NODE_ENV;
173 delete process.env.REDIS_URL;
174
175 await loadConfig();
176
177 expect(warnSpy).not.toHaveBeenCalledWith(
178 expect.stringContaining("in-memory session storage")
179 );
180
181 warnSpy.mockRestore();
182 });
183 });
184
185 describe("Forum credentials", () => {
186 it("loads forum credentials from environment", async () => {
187 process.env.FORUM_HANDLE = "forum.example.com";
188 process.env.FORUM_PASSWORD = "test-password";
189
190 const config = await loadConfig();
191
192 expect(config.forumHandle).toBe("forum.example.com");
193 expect(config.forumPassword).toBe("test-password");
194 });
195
196 it("allows missing forum credentials (optional)", async () => {
197 delete process.env.FORUM_HANDLE;
198 delete process.env.FORUM_PASSWORD;
199
200 const config = await loadConfig();
201
202 expect(config.forumHandle).toBeUndefined();
203 expect(config.forumPassword).toBeUndefined();
204 });
205 });
206
207 describe("Backfill configuration", () => {
208 it("uses default backfill values when env vars not set", async () => {
209 delete process.env.BACKFILL_RATE_LIMIT;
210 delete process.env.BACKFILL_CONCURRENCY;
211 delete process.env.BACKFILL_CURSOR_MAX_AGE_HOURS;
212
213 const config = await loadConfig();
214
215 expect(config.backfillRateLimit).toBe(10);
216 expect(config.backfillConcurrency).toBe(10);
217 expect(config.backfillCursorMaxAgeHours).toBe(48);
218 });
219
220 it("reads backfill values from env vars", async () => {
221 process.env.BACKFILL_RATE_LIMIT = "5";
222 process.env.BACKFILL_CONCURRENCY = "20";
223 process.env.BACKFILL_CURSOR_MAX_AGE_HOURS = "24";
224
225 const config = await loadConfig();
226
227 expect(config.backfillRateLimit).toBe(5);
228 expect(config.backfillConcurrency).toBe(20);
229 expect(config.backfillCursorMaxAgeHours).toBe(24);
230 });
231 });
232});