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
3const mockFetch = vi.fn();
4
5describe("loginRoutes", () => {
6 beforeEach(() => {
7 vi.stubGlobal("fetch", mockFetch);
8 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
9 vi.resetModules();
10
11 // Default: user is not authenticated
12 mockFetch.mockResolvedValue({ ok: false, status: 401 });
13 });
14
15 afterEach(() => {
16 vi.unstubAllGlobals();
17 vi.unstubAllEnvs();
18 mockFetch.mockReset();
19 });
20
21 async function loadLoginRoutes() {
22 const mod = await import("../login.js");
23 return mod.createLoginRoutes("http://localhost:3000");
24 }
25
26 it("renders handle input form for unauthenticated users", async () => {
27 const routes = await loadLoginRoutes();
28 const res = await routes.request("/login");
29
30 expect(res.status).toBe(200);
31 const html = await res.text();
32 expect(html).toContain('name="handle"');
33 expect(html).toContain('type="text"');
34 expect(html).toContain('placeholder="alice.bsky.social"');
35 });
36
37 it("renders login submit button", async () => {
38 const routes = await loadLoginRoutes();
39 const res = await routes.request("/login");
40
41 const html = await res.text();
42 expect(html).toContain("Log in");
43 expect(html).toContain('type="submit"');
44 });
45
46 it("form action points to /api/auth/login (the auth proxy)", async () => {
47 const routes = await loadLoginRoutes();
48 const res = await routes.request("/login");
49
50 const html = await res.text();
51 expect(html).toContain('action="/api/auth/login"');
52 expect(html).toContain('method="get"');
53 });
54
55 it("renders Internet Handle explanation text", async () => {
56 const routes = await loadLoginRoutes();
57 const res = await routes.request("/login");
58
59 const html = await res.text();
60 // Should explain what Internet Handle login means
61 expect(html).toContain("Internet Handle");
62 });
63
64 it("displays decoded error message from query param", async () => {
65 const routes = await loadLoginRoutes();
66 const res = await routes.request(
67 "/login?error=Invalid%20handle%20or%20unable%20to%20find%20your%20PDS."
68 );
69
70 expect(res.status).toBe(200);
71 const html = await res.text();
72 expect(html).toContain("Invalid handle or unable to find your PDS.");
73 });
74
75 it("shows Log in link in header when unauthenticated", async () => {
76 const routes = await loadLoginRoutes();
77 const res = await routes.request("/login");
78
79 const html = await res.text();
80 expect(html).toContain('href="/login"');
81 expect(html).toContain("Log in");
82 expect(html).not.toContain("Log out");
83 });
84
85 it("redirects to / when already authenticated", async () => {
86 mockFetch.mockResolvedValueOnce({
87 ok: true,
88 json: () =>
89 Promise.resolve({
90 authenticated: true,
91 did: "did:plc:xyz",
92 handle: "bob.bsky.social",
93 }),
94 });
95
96 const routes = await loadLoginRoutes();
97 const res = await routes.request("/login", {
98 headers: { cookie: "atbb_session=valid-token" },
99 });
100
101 expect(res.status).toBe(302);
102 expect(res.headers.get("location")).toBe("/");
103 });
104
105 it("displays raw error string when error param has malformed percent-encoding", async () => {
106 const routes = await loadLoginRoutes();
107 // %ZZ is invalid percent-encoding — decodeURIComponent would throw URIError
108 const res = await routes.request("/login?error=%ZZ");
109
110 expect(res.status).toBe(200);
111 const html = await res.text();
112 // Falls back to raw string instead of crashing with 500
113 expect(html).toContain("%ZZ");
114 expect(html).toContain("login-form__error");
115 });
116
117 it("renders no error banner when no error query param present", async () => {
118 const routes = await loadLoginRoutes();
119 const res = await routes.request("/login");
120
121 const html = await res.text();
122 expect(html).not.toContain("login-form__error");
123 });
124});