-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathserver.test.ts
More file actions
4590 lines (4118 loc) · 215 KB
/
Copy pathserver.test.ts
File metadata and controls
4590 lines (4118 loc) · 215 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//// IMPORTS ///////////////////////////////////////////////////////////////////
import { assertEquals, assertExists, assertRejects, assertStringIncludes, assertThrows } from "@std/assert";
import {
buildMark,
buildMsg,
canon,
genKey,
hex,
idOf,
importPriv,
nowSec,
pubHexOf,
type Row,
signRow,
verifyRow,
} from "./dht.ts";
import { backfill } from "./backfill.ts";
import { directImageUrl, getPostedUrls, imageMentionBot, post, R2Conflict, reply, unansweredMentions } from "./bots.ts";
import cowsayBot from "./bots/cowsay.ts";
import { BOTS } from "./bots/mod.ts";
import { jsx } from "@hono/hono/jsx";
import { pgtemp } from "@surprisetalk/pgtemp";
import pg from "postgres";
import { citext } from "@electric-sql/pglite/contrib/citext";
import { hstore } from "@electric-sql/pglite/contrib/hstore";
import "./test_env.ts"; // sets env BEFORE server.tsx module evaluation (ES import order)
import dbSql from "./db.sql" with { type: "text" };
import app, {
AI_CRAWLERS,
badSignupEmail,
botApi,
dbIngestRate,
decodeLabels,
discoverPeers,
emailToken,
encodeLabels,
extractDomains,
extractImageUrl,
extractLinks,
formatBody,
formatLabels,
matchesQ,
paging,
parseLabels,
postRate,
prefRate,
publishPeer,
r2,
refreshStats,
replicate,
resend,
resolveName,
setAssetV,
setSql,
signupRate,
stripe,
verified,
} from "./server.tsx";
//// MOCK SHAPES ///////////////////////////////////////////////////////////////
// Tests monkey-patch Resend and Stripe with narrow stubs; these SDKs don't
// expose testable-mock types, so we define the tiny surface we actually drive.
type SubItem = { id: string; quantity: number };
type Subscription = { items: { data: SubItem[] } };
type UpdateArgs = { items: SubItem[] };
type UpdateCall = { subId: string; args: UpdateArgs };
type EmailMsg = { to: string; subject: string; text: string };
type MockResend = {
emails: {
send: (msg: EmailMsg) => Promise<{ data: { id: string }; error: null }>;
};
};
type MockStripe = {
checkout: {
sessions: {
create: (args?: unknown) => Promise<{ url: string; id: string }>;
retrieve: () => Promise<{
status: string;
subscription: string;
metadata: { orgName: string; creatorName: string };
}>;
};
};
subscriptions: {
retrieve: () => Promise<Subscription>;
update: (subId: string, args: UpdateArgs) => Promise<unknown>;
};
webhooks: {
constructEventAsync: (body: string, sig: string) => Promise<unknown>;
};
__updateCalls: UpdateCall[];
};
const mResend = resend as unknown as MockResend;
const mStripe = stripe as unknown as MockStripe;
// Stub Resend so tests don't make network calls.
const sentEmails: EmailMsg[] = [];
mResend.emails = {
send: (msg) => {
sentEmails.push({ to: msg.to, subject: msg.subject, text: msg.text });
return Promise.resolve({ data: { id: "test_id" }, error: null });
},
};
//// SEED FIXTURES ////////////////////////////////////////////////////////////
// Test fixtures live here (not db.sql) so prod schema applies stay data-free.
// Hardcoded cids (301-357) — many tests reference them by number.
const seedSql = `
insert into usr (name, email, password, bio, email_verified_at, invited_by, orgs_r, orgs_w) values
('BugHunter42', 'bughunter42@example.com', crypt('bugzapper123!', gen_salt('bf', 8)), 'I squash bugs for fun and profit.', null, 'BugHunter42', '{secret,internal}', '{secret}'),
('NullPointerQueen', 'nullpointerqueen@example.com', crypt('segfaults4ever!', gen_salt('bf', 8)), 'Segfaults are my specialty.', null, 'NullPointerQueen', '{secret}', '{secret}'),
('CodeWarrior007', 'codewarrior007@example.com', crypt('goldeneye$', gen_salt('bf', 8)), 'Writing code faster than a speeding bullet.', null, 'CodeWarrior007', '{internal}', '{internal}'),
('StackOverflowLord', 'solord@example.com', crypt('downvote_this!', gen_salt('bf', 8)), 'Living on the edge of recursion.', null, 'StackOverflowLord', '{}', '{}'),
('DebuggerDiva', 'debuggerdiva@example.com', crypt('breakpoint@!', gen_salt('bf', 8)), 'I can debug anything, even your life choices.', null, 'DebuggerDiva', '{secret,internal}', '{secret,internal}'),
('SyntaxSamurai', 'syntaxsamurai@example.com', crypt('semicolon&samurai', gen_salt('bf', 8)), 'Syntax errors fear me.', null, 'SyntaxSamurai', '{}', '{}');
insert into com (cid, parent_cid, created_by, body, tags, orgs, usrs) values
(301, null, 'BugHunter42', 'Why do bugs always show up on Fridays?', '{humor,bugs}', '{}', '{}'),
(302, null, 'NullPointerQueen', 'Just had a null pointer exception. Classic!', '{humor,exceptions}', '{}', '{}'),
(303, 0301, 'NullPointerQueen', 'Bugs love weekends too!', '{humor,bugs}', '{}', '{}'),
(304, null, 'CodeWarrior007', 'Anyone else feel like a coding ninja today?', '{motivation,coding}', '{}', '{}'),
(305, null, 'StackOverflowLord', 'Just downvoted my own answer for fun.', '{humor,meta}', '{}', '{}'),
(306, 0304, 'DebuggerDiva', 'Only when I finally solve that pesky bug.', '{motivation,coding}', '{}', '{}'),
(307, null, 'SyntaxSamurai', 'Semicolon misplaced. It''s a tragedy.', '{humor,syntax}', '{}', '{}'),
(308, 0307, 'CodeWarrior007', 'I feel your pain, syntax samurai.', '{humor,syntax}', '{}', '{}'),
(309, null, 'DebuggerDiva', 'Breakpoints are like checkpoints in life.', '{motivation,debugging}', '{}', '{}'),
(310, 0309, 'BugHunter42', 'And stepping through code is like meditation.', '{motivation,debugging}', '{}', '{}'),
(311, null, 'BugHunter42', 'Just found a bug that only occurs on leap years. FML.', '{humor,bugs}', '{}', '{}'),
(312, 0311, 'NullPointerQueen', 'Those are the best kind. Totally worth the wait.', '{humor,bugs}', '{}', '{}'),
(313, null, 'NullPointerQueen', 'Segfaults are like surprise parties, but with more panic.', '{humor,exceptions}', '{}', '{}'),
(314, null, 'CodeWarrior007', 'Just optimized a function from O(n^2) to O(n log n). I feel like a superhero.', '{motivation,coding}', '{}', '{}'),
(315, 0314, 'SyntaxSamurai', 'Teach me your ways, CodeWarrior007!', '{motivation,coding}', '{}', '{}'),
(316, null, 'StackOverflowLord', 'Just saw someone use a global variable... in 2024. Cringe.', '{humor,coding}', '{}', '{}'),
(317, 0316, 'DebuggerDiva', 'Yikes. That''s a crime against programming.', '{humor,coding}', '{}', '{}'),
(318, null, 'DebuggerDiva', 'Spent 3 hours debugging only to find out I misspelled a variable. Classic.', '{humor,debugging}', '{}', '{}'),
(319, 0318, 'CodeWarrior007', 'Been there, done that. Welcome to the club.', '{humor,debugging}', '{}', '{}'),
(320, null, 'SyntaxSamurai', 'Autocomplete is both a blessing and a curse.', '{humor,coding}', '{}', '{}'),
(321, 0320, 'BugHunter42', 'True, but more blessing when it actually works.', '{humor,coding}', '{}', '{}'),
(322, null, 'BugHunter42', 'Why does every tutorial say "it''s simple" and then proceed to confuse you for hours?', '{humor,learning}', '{}', '{}'),
(323, 0322, 'StackOverflowLord', 'Because they are written by people who forgot how hard it is to learn from scratch.', '{humor,learning}', '{}', '{}'),
(324, null, 'NullPointerQueen', 'My code works. I have no idea why. But it works.', '{humor,coding}', '{}', '{}'),
(325, 0324, 'CodeWarrior007', 'If it ain''t broke, don''t fix it.', '{humor,coding}', '{}', '{}'),
(326, null, 'StackOverflowLord', 'Just spent 2 hours fixing a bug that turned out to be a typo.', '{humor,debugging}', '{}', '{}'),
(327, 0326, 'NullPointerQueen', 'Typos: the silent killers.', '{humor,debugging}', '{}', '{}'),
(328, null, 'DebuggerDiva', 'Breakpoints are my best friends.', '{humor,debugging}', '{}', '{}'),
(329, 0328, 'BugHunter42', 'Especially when you''re deep into spaghetti code.', '{humor,debugging}', '{}', '{}'),
(330, null, 'SyntaxSamurai', 'Why do code reviews feel like therapy sessions?', '{humor,coding}', '{}', '{}'),
(331, 0330, 'DebuggerDiva', 'Because they are! Code is personal.', '{humor,coding}', '{}', '{}'),
(332, null, 'CodeWarrior007', 'Just finished a project without any merge conflicts. Feels like winning the lottery.', '{motivation,coding}', '{}', '{}'),
(333, 0332, 'StackOverflowLord', 'You should definitely buy a lottery ticket today.', '{motivation,coding}', '{}', '{}'),
(334, 0311, 'CodeWarrior007', 'Leap year bugs are like finding Easter eggs... painful ones.', '{humor,bugs}', '{}', '{}'),
(335, 0313, 'StackOverflowLord', 'More panic and less cake, unfortunately.', '{humor,exceptions}', '{}', '{}'),
(336, 0313, 'BugHunter42', 'Segfaults: the ultimate surprise gift from your code.', '{humor,exceptions}', '{}', '{}'),
(337, 0314, 'NullPointerQueen', 'That''s some next-level optimization. Hats off!', '{motivation,coding}', '{}', '{}'),
(338, 0314, 'StackOverflowLord', 'O(n log n)? You must have used some dark magic.', '{motivation,coding}', '{}', '{}'),
(339, 0316, 'BugHunter42', 'Global variables are so last century.', '{humor,coding}', '{}', '{}'),
(340, 0318, 'SyntaxSamurai', 'Nothing like a good variable name typo to humble you.', '{humor,debugging}', '{}', '{}'),
(341, 0318, 'StackOverflowLord', 'Variable typos: the bane of every coder''s existence.', '{humor,debugging}', '{}', '{}'),
(342, 0320, 'NullPointerQueen', 'Autocomplete is the friend who tries too hard.', '{humor,coding}', '{}', '{}'),
(343, 0320, 'DebuggerDiva', 'And sometimes, it''s that annoying friend who finishes your sentences wrong.', '{humor,coding}', '{}', '{}'),
(344, 0322, 'CodeWarrior007', 'It''s their way of saying "Welcome to the real world."', '{humor,learning}', '{}', '{}'),
(345, 0322, 'SyntaxSamurai', 'Because simplicity is a complex concept.', '{humor,learning}', '{}', '{}'),
(346, 0324, 'BugHunter42', 'The mystery of working code: embrace it.', '{humor,coding}', '{}', '{}'),
(347, 0324, 'StackOverflowLord', 'Sometimes code just wants to be mysterious.', '{humor,coding}', '{}', '{}'),
(348, 0326, 'DebuggerDiva', 'Typo bugs: 1, Human: 0.', '{humor,debugging}', '{}', '{}'),
(349, 0328, 'NullPointerQueen', 'Breakpoints are the unsung heroes of debugging.', '{humor,debugging}', '{}', '{}'),
(350, 0328, 'CodeWarrior007', 'Breakpoints and coffee: the ultimate combo.', '{humor,debugging}', '{}', '{}'),
(351, 0330, 'BugHunter42', 'Because they reveal your deepest coding secrets.', '{humor,coding}', '{}', '{}'),
(352, 0330, 'StackOverflowLord', 'It''s a safe space to discuss your code crimes.', '{humor,coding}', '{}', '{}'),
(353, 0332, 'NullPointerQueen', 'Merge conflicts are the worst. Congrats on avoiding them!', '{motivation,coding}', '{}', '{}'),
(354, 0332, 'DebuggerDiva', 'That''s a rare achievement! Celebrate it.', '{motivation,coding}', '{}', '{}'),
(355, null, 'BugHunter42', 'This is a secret post only visible to users with secret tag.', '{humor}', '{secret}', '{}'),
(356, null, 'DebuggerDiva', 'Internal team discussion about upcoming features.', '{coding}', '{internal}', '{}'),
(357, null, 'BugHunter42', 'Direct message to BugHunter42 and DebuggerDiva.', '{general}', '{}', '{BugHunter42,DebuggerDiva}');
insert into usr (name, email, password, bio, email_verified_at, invited_by, orgs_r, orgs_w)
values ('john_doe', 'john@example.com', 'hashed:password1!', 'sample bio', now(), 'john_doe', '{secret}', '{secret}')
on conflict do nothing;
insert into usr (name, email, password, bio, email_verified_at, invited_by, orgs_r, orgs_w)
values ('jane_doe', 'jane@example.com', 'hashed:password1!', 'sample bio', now(), 'john_doe', '{}', '{}')
on conflict do nothing;
select setval('com_cid_seq', (select max(cid) from com));
update com set domains = coalesce((
select array_agg(distinct regexp_replace(lower(rtrim(m[1], '.,;:)]}>')), '^www\\.', ''))
from regexp_matches(body, 'https?://([^/\\s:?#]+)', 'g') as m
), '{}');
-- stat_tag and stat_domain are materialized, so they are empty snapshots until refreshed —
-- and refresh_score reads both, so this has to land first or every seeded score loses its
-- tag and domain terms.
refresh materialized view stat_tag;
refresh materialized view stat_domain;
select refresh_score(array(select cid from com));
`;
//// PGLITE WRAPPER ////////////////////////////////////////////////////////////
// Signup's MX check calls Deno.resolveDns; stub it so tests never touch the network. Real-looking
// domains "resolve"; `.invalid` (RFC 2606, never resolves) throws NotFound to exercise the reject
// path. The fail-open step swaps in its own thrower and restores this.
const fakeResolveDns =
((domain: string, recordType: "MX" | "A") =>
domain.endsWith(".invalid")
? Promise.reject(new Deno.errors.NotFound(`no ${recordType} for ${domain}`))
: Promise.resolve(
recordType === "MX" ? [{ preference: 10, exchange: "mx.test" }] : ["1.2.3.4"],
)) as typeof Deno.resolveDns;
// PGlite has no pgcrypto, so gen_salt/crypt are mocked; the schema's own
// `create extension pgcrypto` is stripped for the same reason.
const setup = [
`create or replace function gen_salt(text, int default 8) returns text language sql as $$ select 'salt' $$;
create or replace function crypt(password text, salt text) returns text language sql as $$
select case when salt like '$%' then password else 'hashed:' || password end
$$;`,
dbSql.replace(/create extension if not exists pgcrypto;/i, ""),
seedSql,
];
// Schema + seed cost the same on every test, so pay once and boot the rest from the
// tarball — pgtemp restores a snapshot ~3x faster than replaying the DDL.
const snapshot = await (async () => {
await using seed = await pgtemp({ extensions: { citext, hstore }, setup });
return await seed.snapshot();
})();
const pgtest = (f: (sql: pg.Sql) => (t: Deno.TestContext) => Promise<void>) => async (t: Deno.TestContext) => {
await using db = await pgtemp({ extensions: { citext, hstore }, snapshot });
// Mock Stripe
mStripe.checkout = {
sessions: {
create: () => Promise.resolve({ url: "https://stripe.com/checkout", id: "cs_test_123" }),
retrieve: () =>
Promise.resolve({
status: "complete",
subscription: "sub_123",
metadata: { orgName: "TestOrg", creatorName: "john_doe" },
}),
},
};
mStripe.__updateCalls = [];
mStripe.subscriptions = {
retrieve: () => Promise.resolve({ items: { data: [{ id: "si_123", quantity: 1 }] } }),
update: (subId, args) => {
mStripe.__updateCalls.push({ subId, args });
return Promise.resolve({});
},
};
mStripe.webhooks = {
constructEventAsync: (body, sig) =>
sig === "valid" ? Promise.resolve(JSON.parse(body)) : Promise.reject(new Error("bad sig")),
};
setSql(db.sql);
postRate.clear();
prefRate.clear();
dbIngestRate.ip.clear();
dbIngestRate.key.clear();
signupRate.ip.clear();
signupRate.perHour = 10_000; // don't throttle the general suite; a dedicated step tests it low
Deno.resolveDns = fakeResolveDns; // hermetic MX check (no live DNS)
await f(db.sql)(t);
};
//// TESTS /////////////////////////////////////////////////////////////////////
const basic = (email: string, pass: string) => ({ Authorization: "Basic " + btoa(`${email}:${pass}`) });
// DATABASE_URL is Neon's transaction-mode `-pooler`, where a named prepared statement outlives
// the client that made it. With prepare on, `alter table ... drop column` invalidates the cached
// plans and every isolate 500s with "cached plan must not change result type". That took ding.bar
// down on 2026-08-09; this pins the setting so it can't be dropped by accident.
Deno.test("postgres client disables prepared statements (Neon pooler is transaction-mode)", () => {
const src = Deno.readTextFileSync(new URL("./server.tsx", import.meta.url));
const opts = src.slice(src.indexOf("export let sql: Sql = pg("), src.indexOf("export const setSql"));
assertStringIncludes(opts, "prepare: false");
});
Deno.test(
"routes",
pgtest((sql) => async (t) => {
await t.step("GET /robots.txt", async () => {
const res = await app.request("/robots.txt");
assertEquals(res.status, 200);
});
await t.step("POST /login wrong credentials redirects to /u with error and prefilled email", async () => {
const body = new FormData();
body.append("email", "john@example.com");
body.append("password", "wrong!");
const res = await app.request("/login", { method: "post", body });
assertEquals(res.status, 302);
const location = res.headers.get("location")!;
assertEquals(location, `/u?error=bad_login&email=${encodeURIComponent("john@example.com")}`);
const followed = await app.request(location);
assertEquals(followed.status, 200);
const html = await followed.text();
assertStringIncludes(html, "wrong email or password");
assertStringIncludes(html, `value="john@example.com"`);
});
await t.step("POST /login unknown email redirects to /signup with prefilled email", async () => {
const body = new FormData();
body.append("email", "nobody@example.com");
body.append("password", "anything");
const res = await app.request("/login", { method: "post", body });
assertEquals(res.status, 302);
const location = res.headers.get("location")!;
assertEquals(location, `/signup?error=email_not_found&email=${encodeURIComponent("nobody@example.com")}`);
const followed = await app.request(location);
assertEquals(followed.status, 200);
const html = await followed.text();
assertStringIncludes(html, "No account with that email");
assertStringIncludes(html, `value="nobody@example.com"`);
});
await t.step("POST /login correct credentials", async () => {
const body = new FormData();
body.append("email", "john@example.com");
body.append("password", "password1!");
const res = await app.request("/login", { method: "post", body });
assertEquals(res.status, 302);
});
await t.step("GET /forgot", async () => {
const res = await app.request("/forgot");
assertEquals(res.status, 200);
});
await t.step("POST /forgot valid email", async () => {
const body = new FormData();
body.append("email", "john@example.com");
const res = await app.request("/forgot", { method: "post", body });
assertEquals(res.status, 302);
});
await t.step("POST /password expired token", async () => {
const body = new FormData();
body.append("email", "john@example.com");
body.append("token", "123:expired_token");
body.append("password", "newpassword1!");
const res = await app.request("/password", { method: "post", body });
assertEquals(res.status, 400); // Invalid or expired token
assertStringIncludes(await res.text(), "expired");
});
await t.step("GET /password with no query params shows expired-link page", async () => {
const res = await app.request("/password");
assertEquals(res.status, 200);
const html = await res.text();
assertStringIncludes(html, "invalid or expired");
assertEquals(html.includes(`name="password"`), false);
});
await t.step("GET /password with stale token shows expired-link page", async () => {
const res = await app.request("/password?email=john@example.com&token=123:bogus");
assertEquals(res.status, 200);
const html = await res.text();
assertStringIncludes(html, "invalid or expired");
assertEquals(html.includes(`name="password"`), false);
});
await t.step("GET /password with valid token shows password form", async () => {
const tok = await emailToken(new Date(), "john@example.com");
const res = await app.request(
`/password?email=${encodeURIComponent("john@example.com")}&token=${encodeURIComponent(tok)}`,
);
assertEquals(res.status, 200);
const html = await res.text();
assertStringIncludes(html, `name="password"`);
assertStringIncludes(html, "john@example.com");
});
await t.step("HTML 404 renders styled error page (not blank)", async () => {
const res = await app.request("/u/nonexistent_user", { headers: { accept: "text/html" } });
assertEquals(res.status, 404);
const html = await res.text();
assertStringIncludes(html, "Not found.");
assertStringIncludes(html, "<html"); // full layout, not bare response
});
await t.step("GET /u without auth shows login form", async () => {
const res = await app.request("/u");
assertEquals(res.status, 200);
const text = await res.text();
assertEquals(text.includes("<h2>login</h2>"), true);
});
await t.step("GET /u/:name valid name", async () => {
const res = await app.request("/u/john_doe");
assertEquals(res.status, 200);
});
await t.step("GET /u/:name invalid name", async () => {
const res = await app.request("/u/nonexistent_user");
assertEquals(res.status, 404);
});
await t.step("GET /c/:cid valid cid", async () => {
const res = await app.request("/c/301");
assertEquals(res.status, 200);
});
await t.step("GET /c all comments", async () => {
const res = await app.request("/c");
assertEquals(res.status, 200);
});
await t.step("GET /c all comments (page 2)", async () => {
const res = await app.request("/c?p=1");
assertEquals(res.status, 200);
});
await t.step("GET / and /c tolerate malformed p/limit (no 500)", async () => {
for (const u of ["/?p=notanumber", "/?p=-5", "/c?p=notanumber", "/c?p=-9", "/c?limit=garbage", "/c?limit=-3"])
assertEquals((await app.request(u)).status, 200, u);
});
// OFFSET is a scan postgres cannot skip, so an unbounded ?p= is a request to walk the
// whole table. The cap is on p * limit, so it means the same depth at any page size.
await t.step("a page past the offset cap is refused, not silently clamped", async () => {
for (const u of ["/?p=99999999", "/c?p=99999999", "/?p=201", "/c?p=201"]) {
const res = await app.request(u);
assertEquals(res.status, 400, u);
assertStringIncludes(await res.text(), "past the last reachable page");
}
// ?limit= moves the page count but not the depth: 5000/100 = 50 pages.
assertEquals((await app.request("/c?limit=100&p=50")).status, 200);
assertEquals((await app.request("/c?limit=100&p=51")).status, 400);
// The last page inside the cap still works, so the bound is off-by-one clean.
assertEquals((await app.request("/?p=200")).status, 200);
});
// A browser must never be able to click its way into that 400. Shrink the cap rather
// than seeding 5000 rows: at the default a page that deep returns nothing, so `more`
// would be false anyway and the assertion would prove nothing.
await t.step("the next link disappears at the cap", async () => {
// Two full pages of public roots, so `more` turns on the item count and the cap is
// the only thing that can turn it back off.
await sql`insert into com (created_by, body, tags, created_at, score)
select 'BugHunter42', 'pagecap ' || g, '{pagecap}', now(), now()
from generate_series(1, 30) g`;
try {
paging.maxOffset = 25; // last reachable page is p=1
assertStringIncludes(await (await app.request("/")).text(), "p=1", "page 0 should still offer next");
const atCap = await (await app.request("/?p=1")).text();
assertStringIncludes(atCap, 'class="posts"'); // still a full page of results...
assertEquals(atCap.includes("p=2"), false, "rendered a next link past the cap");
// ...and the page it would have linked to is exactly what the handler refuses.
assertEquals((await app.request("/?p=2")).status, 400);
} finally {
paging.maxOffset = 5000;
await sql`delete from com where tags @> '{pagecap}'`;
}
});
// This file shipped as one string containing "\\n", i.e. LITERAL backslash-n, so every
// crawler saw a single unparseable line and ding had no rules at all.
// 2026-08-13: a database wobble took the WHOLE site down, robots.txt included, because
// the middleware awaited refreshVerified() on every request with no error handling. These
// two steps are the regression guard: a failing database must cost checkmarks, not the site.
await t.step("robots.txt touches the database zero times", async () => {
let calls = 0;
verified.at = 0; // force the state where the middleware WOULD query, if it ran at all
try {
setSql(new Proxy(sql, { apply: (t, self, a) => (calls++, Reflect.apply(t as never, self, a)) }));
const res = await app.request("/robots.txt");
assertEquals(res.status, 200);
assertStringIncludes(await res.text(), "User-agent: *");
assertEquals(calls, 0, "a constant response should never depend on the database");
} finally {
setSql(sql);
}
});
await t.step("a failing refreshVerified degrades to no checkmarks, not a 500", async () => {
let calls = 0;
verified.at = 0; // else the 60s cache from earlier steps means it never re-runs
try {
// Only the verified-set query fails; every other query is served normally.
setSql(
new Proxy(sql, {
apply: (t, self, a) => {
const q = String((a[0] as string[])?.join?.("|") ?? "");
if (q.includes("kind = 'mark'") && q.includes("from usr u")) {
calls++;
return Promise.reject(new Error("connection refused"));
}
return Reflect.apply(t as never, self, a);
},
}),
);
assertEquals((await app.request("/c/301")).status, 200, "a failed verified refresh 500'd the page");
// ...and it must not retry on every request — that herd is what turns a slow
// database into a dead site.
for (let i = 0; i < 5; i++) await app.request("/c/301");
assertEquals(calls, 1, `refreshVerified ran ${calls} times instead of backing off after the failure`);
} finally {
setSql(sql);
}
});
await t.step("robots.txt is a real multi-line file", async () => {
const res = await app.request("/robots.txt");
assertEquals(res.status, 200);
const txt = await res.text();
assertEquals(txt.includes("\\n"), false, "robots.txt contains a literal backslash-n again");
const lines = txt.split("\n").map((l) => l.trim());
assertEquals(lines[0], "User-agent: *");
assertEquals(lines.includes("Disallow: /*?"), true);
assertEquals(lines.includes("Sitemap: https://ding.bar/sitemap.txt"), true);
// Every blocked crawler needs its own User-agent line followed by a Disallow.
for (const ua of AI_CRAWLERS) {
const at = lines.indexOf(`User-agent: ${ua}`);
assertEquals(at >= 0, true, `${ua} missing from robots.txt`);
assertEquals(lines[at + 1], "Disallow: /", `${ua} has no Disallow`);
}
});
// robots.txt is advisory and the heaviest scrapers ignore it, so the same policy is
// enforced in the middleware — unlike botRe's, this block is not query-string-only.
await t.step("training scrapers are refused on every path", async () => {
for (const ua of AI_CRAWLERS) {
for (const path of ["/", "/c", "/c/301", "/u/BugHunter42"]) {
const res = await app.request(path, { headers: { "User-Agent": `Mozilla/5.0 (compatible; ${ua}/1.0)` } });
assertEquals(res.status, 403, `${ua} got ${res.status} on ${path}`);
}
}
});
// A 403 to a search engine delists the site, so the hard block must never catch one.
await t.step("search engines still reach content URLs", async () => {
for (
const ua of [
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
"Mozilla/5.0 (compatible; DuckDuckBot/1.1)",
]
) {
assertEquals((await app.request("/", { headers: { "User-Agent": ua } })).status, 200, ua);
assertEquals((await app.request("/c/301", { headers: { "User-Agent": ua } })).status, 200, ua);
// ...but the infinite filter space still costs them a 403, as before.
assertEquals((await app.request("/c?tag=humor", { headers: { "User-Agent": ua } })).status, 403, ua);
}
});
await t.step("GET /verify invalid token", async () => {
const res = await app.request("/verify?email=john@example.com&token=123:invalid_token");
assertEquals(res.status, 400); // Invalid or expired token
});
await t.step("GET /signup shows form", async () => {
const res = await app.request("/signup");
assertEquals(res.status, 200);
const html = await res.text();
assertEquals(html.includes(`name="name"`), true);
assertEquals(html.includes(`name="email"`), true);
});
await t.step("POST /signup creates unverified user and redirects to ?ok", async () => {
const body = new FormData();
body.append("name", "fresh_user");
body.append("email", "fresh@example.com");
const res = await app.request("/signup", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "/signup?ok");
const [u] = await sql`select name, email, password, email_verified_at from usr where name = 'fresh_user'`;
assertEquals(u.email, "fresh@example.com");
assertEquals(u.password, null);
assertEquals(u.email_verified_at, null);
});
await t.step("POST /signup duplicate name (different email) redirects to ?error=name_taken", async () => {
const body = new FormData();
body.append("name", "john_doe");
body.append("email", "different@example.com");
const res = await app.request("/signup", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
`/signup?error=name_taken&email=${encodeURIComponent("different@example.com")}`,
);
const [{ count }] = await sql`select count(*)::int as count from usr where name = 'john_doe'`;
assertEquals(count, 1);
});
await t.step("POST /signup duplicate verified email redirects to ?error=already_verified", async () => {
const body = new FormData();
body.append("name", "different_name");
body.append("email", "john@example.com");
const res = await app.request("/signup", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
`/signup?error=already_verified&email=${encodeURIComponent("john@example.com")}`,
);
const [{ count }] = await sql`select count(*)::int as count from usr where name = 'different_name'`;
assertEquals(count, 0);
});
await t.step("POST /signup duplicate unverified email re-sends and redirects to ?ok", async () => {
// fresh_user from earlier step is unverified
const body = new FormData();
body.append("name", "yet_another");
body.append("email", "fresh@example.com");
const res = await app.request("/signup", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "/signup?ok");
// No new row inserted under the second name
const [{ count }] = await sql`select count(*)::int as count from usr where name = 'yet_another'`;
assertEquals(count, 0);
});
await t.step("POST /signup/resend for unverified email redirects to ?resent", async () => {
const body = new FormData();
body.append("email", "fresh@example.com");
const res = await app.request("/signup/resend", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "/signup?resent");
});
await t.step("POST /signup/resend for verified email redirects to ?error=already_verified", async () => {
const body = new FormData();
body.append("email", "john@example.com");
const res = await app.request("/signup/resend", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
`/signup?error=already_verified&email=${encodeURIComponent("john@example.com")}`,
);
});
await t.step("POST /signup/resend for unknown email redirects to ?error=conflict", async () => {
const body = new FormData();
body.append("email", "nobody@example.com");
const res = await app.request("/signup/resend", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
`/signup?error=conflict&email=${encodeURIComponent("nobody@example.com")}`,
);
});
await t.step("POST /signup honeypot filled → silent ?ok, no account created", async () => {
const body = new FormData();
body.append("name", "hp_bot_user");
body.append("email", "hpbot@example.com");
body.append("url", "http://spam.example"); // bots fill the hidden field
const res = await app.request("/signup", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "/signup?ok");
const [{ count }] = await sql`select count(*)::int as count from usr where name = 'hp_bot_user'`;
assertEquals(count, 0);
});
await t.step("POST /signup disposable domain → ?error=bad_email, no account created", async () => {
const body = new FormData();
body.append("name", "disposable_user");
body.append("email", "throwaway@mailinator.com");
const res = await app.request("/signup", { method: "POST", body });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
`/signup?error=bad_email&email=${encodeURIComponent("throwaway@mailinator.com")}`,
);
const [{ count }] = await sql`select count(*)::int as count from usr where name = 'disposable_user'`;
assertEquals(count, 0);
});
await t.step("POST /signup domain with no MX/A → ?error=bad_email, no account created", async () => {
const body = new FormData();
body.append("name", "nomx_user");
body.append("email", "someone@nxdomain.invalid"); // fakeResolveDns throws NotFound for .invalid
const res = await app.request("/signup", { method: "POST", body });
assertEquals(res.status, 302);
assertStringIncludes(res.headers.get("location") ?? "", "/signup?error=bad_email");
const [{ count }] = await sql`select count(*)::int as count from usr where name = 'nomx_user'`;
assertEquals(count, 0);
});
await t.step("badSignupEmail fails OPEN on a transient resolver error", async () => {
Deno.resolveDns = (() => Promise.reject(new Error("resolver timeout"))) as typeof Deno.resolveDns;
try {
// Non-disposable domain, resolver errors non-NotFound → must NOT be rejected.
assertEquals(await badSignupEmail("real@some-legit-domain.com"), null);
} finally {
Deno.resolveDns = fakeResolveDns;
}
});
await t.step("POST /signup per-IP throttle → Nth+1 attempt from same IP is 429", async () => {
const saved = signupRate.perHour;
signupRate.perHour = 3;
signupRate.ip.clear();
const ip = "203.0.113.7";
try {
for (let i = 0; i < 3; i++) {
const body = new FormData();
body.append("name", `ratelimited_${i}`);
body.append("email", `rl${i}@example.com`);
const res = await app.request("/signup", {
method: "POST",
body,
headers: { "cf-connecting-ip": ip },
});
assertEquals(res.status, 302, `attempt ${i} should pass`);
}
const body = new FormData();
body.append("name", "ratelimited_over");
body.append("email", "rlover@example.com");
const res = await app.request("/signup", {
method: "POST",
body,
headers: { "cf-connecting-ip": ip },
});
assertEquals(res.status, 429);
} finally {
signupRate.perHour = saved;
signupRate.ip.clear();
}
});
await t.step("GET /us lists verified accounts and excludes unverified", async () => {
// john_doe is verified (john@example.com); fresh_user was created unverified earlier.
const res = await app.request("/us");
assertEquals(res.status, 200);
const names = ((await res.json()) as { name: string }[]).map((u) => u.name);
assertEquals(names.includes("john_doe"), true);
assertEquals(names.includes("fresh_user"), false);
});
await t.step(
"sendVerify cooldown: two POSTs to /signup/resend within 5min trigger only one Resend send",
async () => {
const body = new FormData();
body.append("name", "cooldown_user");
body.append("email", "cooldown@example.com");
await app.request("/signup", { method: "POST", body }); // creates + first send
const before = sentEmails.filter((m) => m.to === "cooldown@example.com").length;
const r1 = new FormData();
r1.append("email", "cooldown@example.com");
const res1 = await app.request("/signup/resend", { method: "POST", body: r1 });
assertEquals(res1.status, 302);
assertEquals(res1.headers.get("location"), "/signup?resent");
const r2 = new FormData();
r2.append("email", "cooldown@example.com");
const res2 = await app.request("/signup/resend", { method: "POST", body: r2 });
assertEquals(res2.status, 302);
assertEquals(res2.headers.get("location"), "/signup?resent");
const after = sentEmails.filter((m) => m.to === "cooldown@example.com").length;
assertEquals(after - before, 0); // both extra calls suppressed by cooldown
// Backdating verify_sent_at past the cooldown allows another send.
await sql`update usr set verify_sent_at = now() - interval '10 minutes' where email = 'cooldown@example.com'`;
const r3 = new FormData();
r3.append("email", "cooldown@example.com");
const res3 = await app.request("/signup/resend", { method: "POST", body: r3 });
assertEquals(res3.status, 302);
const afterReset = sentEmails.filter((m) => m.to === "cooldown@example.com").length;
assertEquals(afterReset - after, 1);
},
);
await t.step("GET /signup renders error message for ?error=name_taken", async () => {
const res = await app.request("/signup?error=name_taken&email=x%40y.com");
const html = await res.text();
assertEquals(html.includes("already taken"), true);
});
await t.step("GET /verify with valid token sets email_verified_at for signup user", async () => {
const tok = await emailToken(new Date(), "fresh@example.com");
const res = await app.request(
`/verify?email=${encodeURIComponent("fresh@example.com")}&token=${encodeURIComponent(tok)}`,
);
assertEquals(res.status < 400, true);
const [u] = await sql`select email_verified_at from usr where name = 'fresh_user'`;
assertEquals(u.email_verified_at !== null, true);
});
await t.step("GET /u with valid credentials", async () => {
const res = await app.request("/u", {
headers: {
...basic("john@example.com", "password1!"),
},
});
assertEquals(res.status, 200);
});
await t.step("GET /u with invalid credentials", async () => {
const res = await app.request("/u", {
headers: basic("john@example.com", "wrong!"),
});
assertEquals(res.status, 401);
});
await t.step("GET /u with next param shows login form with redirect", async () => {
const res = await app.request("/u?next=%2Fc%2F123");
assertEquals(res.status, 200);
const text = await res.text();
assertEquals(text.includes("/login?next=%2Fc%2F123"), true);
});
await t.step("GET / (default hot sort)", async () => {
const res = await app.request("/");
assertEquals(res.status, 200);
});
await t.step("pages load client.js from /public, not inline", async () => {
const text = await (await app.request("/")).text();
assertStringIncludes(text, `<script src="/client.js" defer></script>`);
assertEquals(text.includes("document.querySelectorAll"), false);
const js = await app.request("/client.js");
assertEquals(js.status, 200);
assertStringIncludes(await js.text(), "ding:compose-body");
});
// Unversioned (local dev): assets must stay revalidated so edits show up.
await t.step("assets are not cached without a deploy version", async () => {
for (const p of ["/client.js", "/style.css", "/client.js?v=whatever"])
assertEquals((await app.request(p)).headers.get("cache-control"), null, p);
});
// Versioned (deployed): ?v=<DENO_DEPLOYMENT_ID> is a fresh URL every deploy, so the old
// one is never requested again and immutable is safe. Tests can't set the real env var —
// that would make Deno.cron register the bot fleet — hence setAssetV.
await t.step("a versioned asset URL is immutable, a bare or stale one is not", async () => {
setAssetV("deploy123");
try {
const html = await (await app.request("/")).text();
assertStringIncludes(html, `<script src="/client.js?v=deploy123" defer></script>`);
assertStringIncludes(html, `<link rel="stylesheet" href="/style.css?v=deploy123" />`);
assertStringIncludes(
await (await app.request("/embed?url=https://x.example/a")).text(),
`href="https://ding.bar/style.css?v=deploy123"`,
);
const cc = async (p: string) => (await app.request(p)).headers.get("cache-control");
assertEquals(await cc("/client.js?v=deploy123"), "public, max-age=31536000, immutable");
assertEquals(await cc("/style.css?v=deploy123"), "public, max-age=31536000, immutable");
assertEquals(await cc("/client.js"), null); // bare path must not be pinned for a year
assertEquals(await cc("/client.js?v=olddeploy"), null); // a stale version must revalidate
} finally {
setAssetV("");
}
});
await t.step("data-unread is present only for logged-in viewers", async () => {
assertEquals((await (await app.request("/")).text()).includes("data-unread"), false);
const loginBody = new FormData();
loginBody.append("email", "john@example.com");
loginBody.append("password", "password1!");
const boot = await app.request("/login", { method: "POST", body: loginBody });
const cookie = boot.headers.get("set-cookie")!.split(";")[0];
assertStringIncludes(await (await app.request("/", { headers: { cookie } })).text(), `data-unread="`);
});
// Regression: the p param must be REPLACED, not appended. c.req.query reads the first
// value, so an appended p made every prev/next link back to the page you were on.
await t.step("pagination links replace p instead of appending it", async () => {
const html = await (await app.request("/?p=1&sort=new")).text();
const links = [...html.matchAll(/<a href="([^"]*p=[^"]*)"[^>]*>(prev|next)</g)].map((m) => m[1]);
assertEquals(links.length > 0, true);
for (const href of links) {
const ps = new URLSearchParams(href.split("?")[1]).getAll("p");
assertEquals(ps.length, 1);
assertEquals(ps[0] === "1", false);
}
assertStringIncludes(html, "sort=new");
});
await t.step("GET /?sort=new", async () => {
const res = await app.request("/?sort=new");
assertEquals(res.status, 200);
});
await t.step("GET /?sort=top", async () => {
const res = await app.request("/?sort=top");
assertEquals(res.status, 200);
});
await t.step("GET /c with tag filter", async () => {
const res = await app.request("/c?tag=humor");
assertEquals(res.status, 200);
});
await t.step("GET /c with multiple tag filters", async () => {
const res = await app.request("/c?tag=humor&tag=bugs");
assertEquals(res.status, 200);
});
await t.step("GET /c/:cid for private post (access denied - shows 404)", async () => {
// 355 is a secret post in db.sql. Unauthenticated access should return 404 for privacy.
const res = await app.request("/c/355");
assertEquals(res.status, 404);
});
await t.step("GET /c/:cid for non-existent post (404)", async () => {
const res = await app.request("/c/999999");
assertEquals(res.status, 404);
});
await t.step("GET /c/:cid logged out shows signup form", async () => {
const res = await app.request("/c/301");
assertEquals(res.status, 200);
const html = await res.text();
assertEquals(html.includes("create an account to reply"), true);
assertEquals(html.includes(`action="/signup"`), true);
assertEquals(html.includes(`pattern="^[0-9a-zA-Z_]{4,32}$"`), true);
assertEquals(html.includes(`/u?next=%2Fc%2F301`), true);
});
await t.step("GET /c?tag=humor renders single-tag header and 'post to' action", async () => {
const res = await app.request("/c?tag=humor");
const html = await res.text();
assertEquals(html.includes(`<h2>#humor</h2>`), true);
assertEquals(html.includes("post to #humor"), true);
assertEquals(html.includes(`href="/?tag=humor"`), true);
});
await t.step("GET /c?tag=humor&tag=bugs does not render single-tag header", async () => {
const res = await app.request("/c?tag=humor&tag=bugs");
const html = await res.text();
assertEquals(html.includes("post to #humor"), false);
assertEquals(html.includes("post to #bugs"), false);
});
await t.step("GET /c?usr=BugHunter42 renders single-user header and 'post to' action", async () => {
const res = await app.request("/c?usr=BugHunter42");
const html = await res.text();
assertEquals(html.includes(`<h2>@BugHunter42</h2>`), true);
assertEquals(html.includes(`href="/u/BugHunter42"`), true);
assertEquals(html.includes("post to @BugHunter42"), true);
});
await t.step("GET /c?org=secret renders single-org header for member", async () => {
const loginBody = new FormData();
loginBody.append("email", "john@example.com");
loginBody.append("password", "password1!");
const boot = await app.request("/login", { method: "POST", body: loginBody });
const cookie = boot.headers.get("set-cookie")!.split(";")[0];
const res = await app.request("/c?org=secret", { headers: { cookie } });
const html = await res.text();
assertEquals(html.includes(`<h2>*secret</h2>`), true);
assertEquals(html.includes("post to *secret"), true);
});
await t.step("GET /c with Accept: application/json returns JSON array", async () => {
const res = await app.request("/c", { headers: { Accept: "application/json" } });
assertEquals(res.status, 200);
const data = await res.json();
assertEquals(Array.isArray(data), true);
assertEquals(data.length > 0, true);
});
await t.step("GET /c with browser Accept header returns HTML, not RSS", async () => {
const res = await app.request("/c", {
headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
});
assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type")?.includes("text/html"), true);
const body = await res.text();
assertEquals(body.startsWith("<?xml"), false);
});
await t.step("GET /c with feed-reader Accept returns RSS", async () => {
const res = await app.request("/c", { headers: { Accept: "application/rss+xml" } });
assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type")?.includes("xml"), true);
const body = await res.text();
assertEquals(body.startsWith("<?xml"), true);
});
await t.step("GET /c/:cid with Accept: application/json returns JSON", async () => {
const res = await app.request("/c/301", { headers: { Accept: "application/json" } });
assertEquals(res.status, 200);
const data = await res.json();
assertEquals(Array.isArray(data), true);
assertEquals(data[0].cid, 301);
assertEquals(data[0].created_by, "BugHunter42");
});
await t.step("GET /u/:name JSON as non-owner hides orgs_r/orgs_w", async () => {
const res = await app.request("/u/john_doe", { headers: { Accept: "application/json" } });
assertEquals(res.status, 200);
const body = await res.json();
assertEquals(body.name, "john_doe");