Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
1import { beforeEach, describe, expect, it } from "vitest";
2import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3import Login from "../routes/Login.svelte";
4import {
5 clearMocks,
6 jsonResponse,
7 mockData,
8 mockEndpoint,
9 setupFetchMock,
10 setupIndexedDBMock,
11} from "./mocks.ts";
12import { _testSetState, type SavedAccount } from "../lib/auth.svelte.ts";
13import {
14 unsafeAsAccessToken,
15 unsafeAsDid,
16 unsafeAsHandle,
17 unsafeAsRefreshToken,
18} from "../lib/types/branded.ts";
19import { getToasts } from "../lib/toast.svelte.ts";
20
21describe("Login", () => {
22 beforeEach(() => {
23 clearMocks();
24 setupFetchMock();
25 setupIndexedDBMock();
26 mockEndpoint(
27 "/oauth/par",
28 () => jsonResponse({ request_uri: "urn:mock:request" }),
29 );
30 });
31
32 describe("initial render with no saved accounts", () => {
33 beforeEach(() => {
34 _testSetState({
35 session: null,
36 loading: false,
37 error: null,
38 savedAccounts: [],
39 });
40 });
41
42 it("renders login page with title and OAuth button", async () => {
43 render(Login);
44 await waitFor(() => {
45 expect(screen.getByRole("heading", { name: /sign in/i }))
46 .toBeInTheDocument();
47 expect(screen.getByRole("button", { name: /sign in/i }))
48 .toBeInTheDocument();
49 });
50 });
51
52 it("shows create account link", async () => {
53 render(Login);
54 await waitFor(() => {
55 expect(screen.getByText(/no account\?/i)).toBeInTheDocument();
56 expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute(
57 "href",
58 "/app/register",
59 );
60 });
61 });
62
63 it("shows forgot password and lost passkey links", async () => {
64 render(Login);
65 await waitFor(() => {
66 expect(screen.getByRole("link", { name: /forgot password/i }))
67 .toHaveAttribute("href", "/app/reset-password");
68 expect(screen.getByRole("link", { name: /lost passkey/i }))
69 .toHaveAttribute("href", "/app/request-passkey-recovery");
70 });
71 });
72 });
73
74 describe("with saved accounts", () => {
75 const savedAccounts: SavedAccount[] = [
76 {
77 did: unsafeAsDid("did:web:test.tranquil.dev:u:alice"),
78 handle: unsafeAsHandle("alice.test.tranquil.dev"),
79 accessJwt: unsafeAsAccessToken("mock-jwt-alice"),
80 refreshJwt: unsafeAsRefreshToken("mock-refresh-alice"),
81 },
82 {
83 did: unsafeAsDid("did:web:test.tranquil.dev:u:bob"),
84 handle: unsafeAsHandle("bob.test.tranquil.dev"),
85 accessJwt: unsafeAsAccessToken("mock-jwt-bob"),
86 refreshJwt: unsafeAsRefreshToken("mock-refresh-bob"),
87 },
88 ];
89
90 beforeEach(() => {
91 _testSetState({
92 session: null,
93 loading: false,
94 error: null,
95 savedAccounts,
96 });
97 mockEndpoint(
98 "com.atproto.server.getSession",
99 () =>
100 jsonResponse(
101 mockData.session({
102 handle: unsafeAsHandle("alice.test.tranquil.dev"),
103 }),
104 ),
105 );
106 });
107
108 it("displays saved accounts list", async () => {
109 render(Login);
110 await waitFor(() => {
111 expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
112 .toBeInTheDocument();
113 expect(screen.getByText(/@bob\.test\.tranquil\.dev/))
114 .toBeInTheDocument();
115 });
116 });
117
118 it("shows sign in to another account option", async () => {
119 render(Login);
120 await waitFor(() => {
121 expect(screen.getByText(/sign in to another/i)).toBeInTheDocument();
122 });
123 });
124
125 it("can click on saved account to switch", async () => {
126 render(Login);
127 await waitFor(() => {
128 expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
129 .toBeInTheDocument();
130 });
131 const aliceAccount = screen.getByText(/@alice\.test\.tranquil\.dev/)
132 .closest("[role='button']");
133 if (aliceAccount) {
134 await fireEvent.click(aliceAccount);
135 }
136 await waitFor(() => {
137 expect(globalThis.location.pathname).toBe("/app/dashboard");
138 });
139 });
140
141 it("can remove saved account with forget button", async () => {
142 render(Login);
143 await waitFor(() => {
144 expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
145 .toBeInTheDocument();
146 const forgetButtons = screen.getAllByTitle(/remove/i);
147 expect(forgetButtons.length).toBe(2);
148 });
149 });
150 });
151
152 describe("error handling", () => {
153 it("displays error message as toast when auth state has error", async () => {
154 _testSetState({
155 session: null,
156 loading: false,
157 error: "OAuth login failed",
158 savedAccounts: [],
159 });
160 render(Login);
161 await waitFor(() => {
162 const toasts = getToasts();
163 const errorToast = toasts.find(
164 (t) => t.type === "error" && t.message.includes("OAuth login failed"),
165 );
166 expect(errorToast).toBeDefined();
167 });
168 });
169 });
170
171 describe("verification flow", () => {
172 beforeEach(() => {
173 _testSetState({
174 session: null,
175 loading: false,
176 error: null,
177 savedAccounts: [],
178 });
179 });
180
181 it("shows verification form when pending verification exists", () => {
182 render(Login);
183 });
184 });
185
186 describe("loading state", () => {
187 it("shows loading state while auth is initializing", () => {
188 _testSetState({
189 session: null,
190 loading: true,
191 error: null,
192 savedAccounts: [],
193 });
194 render(Login);
195 });
196 });
197});