sveltekit-auth-example 5.6.1 → 5.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.env.example +5 -3
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +24 -18
  4. package/db_create.sql +0 -1
  5. package/db_schema.sql +3 -3
  6. package/package.json +8 -5
  7. package/src/app.d.ts +125 -61
  8. package/src/app.html +6 -0
  9. package/src/hooks.server.unit.test.ts +163 -0
  10. package/src/lib/Turnstile.svelte +53 -0
  11. package/src/lib/app-state.svelte.unit.test.ts +73 -0
  12. package/src/lib/auth-redirect.unit.test.ts +92 -0
  13. package/src/lib/fetch-interceptor.unit.test.ts +99 -0
  14. package/src/lib/focus.unit.test.ts +91 -0
  15. package/src/lib/google.ts +41 -17
  16. package/src/lib/google.unit.test.ts +189 -0
  17. package/src/lib/server/brevo.ts +179 -0
  18. package/src/lib/server/brevo.unit.test.ts +186 -0
  19. package/src/lib/server/db.unit.test.ts +91 -0
  20. package/src/lib/server/email/mfa-code.ts +6 -6
  21. package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
  22. package/src/lib/server/email/password-reset.ts +7 -7
  23. package/src/lib/server/email/password-reset.unit.test.ts +70 -0
  24. package/src/lib/server/email/verify-email.ts +7 -7
  25. package/src/lib/server/email/verify-email.unit.test.ts +79 -0
  26. package/src/lib/server/turnstile.ts +29 -0
  27. package/src/lib/server/turnstile.unit.test.ts +111 -0
  28. package/src/routes/api/v1/user/+server.unit.test.ts +114 -0
  29. package/src/routes/auth/[slug]/+server.unit.test.ts +15 -0
  30. package/src/routes/auth/forgot/+server.ts +9 -2
  31. package/src/routes/auth/forgot/+server.unit.test.ts +110 -0
  32. package/src/routes/auth/google/+server.unit.test.ts +132 -0
  33. package/src/routes/auth/login/+server.ts +8 -3
  34. package/src/routes/auth/login/+server.unit.test.ts +221 -0
  35. package/src/routes/auth/logout/+server.unit.test.ts +85 -0
  36. package/src/routes/auth/mfa/+server.ts +8 -3
  37. package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
  38. package/src/routes/auth/register/+server.ts +14 -3
  39. package/src/routes/auth/register/+server.unit.test.ts +182 -0
  40. package/src/routes/auth/reset/+server.ts +8 -2
  41. package/src/routes/auth/reset/+server.unit.test.ts +139 -0
  42. package/src/routes/auth/reset/[token]/+page.svelte +11 -1
  43. package/src/routes/auth/verify/[token]/+server.ts +2 -2
  44. package/src/routes/auth/verify/[token]/+server.unit.test.ts +124 -0
  45. package/src/routes/forgot/+page.svelte +9 -1
  46. package/src/routes/login/+page.svelte +28 -5
  47. package/src/routes/register/+page.svelte +11 -1
  48. package/src/service-worker.unit.test.ts +228 -0
  49. package/svelte.config.js +4 -2
  50. package/vite.config.ts +5 -10
  51. package/src/lib/server/sendgrid.ts +0 -22
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { appState } from './app-state.svelte'
3
+
4
+ describe('appState', () => {
5
+ beforeEach(() => {
6
+ // Reset to initial state before each test
7
+ appState.user = undefined
8
+ appState.toast = { title: '', body: '', isOpen: false }
9
+ appState.googleInitialized = false
10
+ })
11
+
12
+ describe('initial state', () => {
13
+ it('user is undefined', () => {
14
+ expect(appState.user).toBeUndefined()
15
+ })
16
+
17
+ it('toast starts closed with empty strings', () => {
18
+ expect(appState.toast).toEqual({ title: '', body: '', isOpen: false })
19
+ })
20
+
21
+ it('googleInitialized is false', () => {
22
+ expect(appState.googleInitialized).toBe(false)
23
+ })
24
+ })
25
+
26
+ describe('user', () => {
27
+ it('can be set to a user object', () => {
28
+ const user: User = {
29
+ id: 1,
30
+ role: 'admin',
31
+ email: 'admin@example.com',
32
+ firstName: 'Jane',
33
+ lastName: 'Doe',
34
+ phone: '412-555-1212',
35
+ optOut: false
36
+ }
37
+
38
+ appState.user = user
39
+
40
+ expect(appState.user).toEqual(user)
41
+ })
42
+
43
+ it('can be cleared back to undefined', () => {
44
+ appState.user = { id: 1, role: 'student', email: 'a@b.com', firstName: 'A', lastName: 'B', phone: '', optOut: false }
45
+ appState.user = undefined
46
+ expect(appState.user).toBeUndefined()
47
+ })
48
+ })
49
+
50
+ describe('toast', () => {
51
+ it('can be updated to show a notification', () => {
52
+ appState.toast = { title: 'Success', body: 'Your changes were saved.', isOpen: true }
53
+
54
+ expect(appState.toast).toEqual({ title: 'Success', body: 'Your changes were saved.', isOpen: true })
55
+ })
56
+
57
+ it('isOpen can be toggled independently', () => {
58
+ appState.toast = { title: 'Hey', body: 'Hello', isOpen: true }
59
+ appState.toast.isOpen = false
60
+
61
+ expect(appState.toast.isOpen).toBe(false)
62
+ expect(appState.toast.title).toBe('Hey')
63
+ })
64
+ })
65
+
66
+ describe('googleInitialized', () => {
67
+ it('can be set to true', () => {
68
+ appState.googleInitialized = true
69
+
70
+ expect(appState.googleInitialized).toBe(true)
71
+ })
72
+ })
73
+ })
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
4
+ vi.mock('$app/state', () => ({ page: { url: { searchParams: new URLSearchParams() } } }))
5
+
6
+ import { redirectAfterLogin } from './auth-redirect'
7
+ import { goto } from '$app/navigation'
8
+ import { page } from '$app/state'
9
+
10
+ const mockGoto = vi.mocked(goto)
11
+ const mockPage = page as { url: { searchParams: URLSearchParams } }
12
+
13
+ const student: UserProperties = { id: 1, role: 'student' }
14
+ const teacher: UserProperties = { id: 2, role: 'teacher' }
15
+ const admin: UserProperties = { id: 3, role: 'admin' }
16
+
17
+ describe('redirectAfterLogin', () => {
18
+ beforeEach(() => {
19
+ mockGoto.mockReset()
20
+ mockPage.url.searchParams = new URLSearchParams()
21
+ })
22
+
23
+ // ── No-op cases ────────────────────────────────────────────────────────────
24
+
25
+ it('does nothing when user is undefined', () => {
26
+ redirectAfterLogin(undefined)
27
+ expect(mockGoto).not.toHaveBeenCalled()
28
+ })
29
+
30
+ it('does nothing when user is null', () => {
31
+ redirectAfterLogin(null)
32
+ expect(mockGoto).not.toHaveBeenCalled()
33
+ })
34
+
35
+ // ── Role-based defaults ────────────────────────────────────────────────────
36
+
37
+ it('redirects a student to /', () => {
38
+ redirectAfterLogin(student)
39
+ expect(mockGoto).toHaveBeenCalledWith('/')
40
+ })
41
+
42
+ it('redirects a teacher to /teachers', () => {
43
+ redirectAfterLogin(teacher)
44
+ expect(mockGoto).toHaveBeenCalledWith('/teachers')
45
+ })
46
+
47
+ it('redirects an admin to /admin', () => {
48
+ redirectAfterLogin(admin)
49
+ expect(mockGoto).toHaveBeenCalledWith('/admin')
50
+ })
51
+
52
+ // ── Valid referrer ─────────────────────────────────────────────────────────
53
+
54
+ it('redirects to the referrer path instead of the role default', () => {
55
+ mockPage.url.searchParams = new URLSearchParams({ referrer: '/dashboard' })
56
+ redirectAfterLogin(admin)
57
+ expect(mockGoto).toHaveBeenCalledWith('/dashboard')
58
+ })
59
+
60
+ it('uses the referrer for any role', () => {
61
+ mockPage.url.searchParams = new URLSearchParams({ referrer: '/some/path' })
62
+ redirectAfterLogin(teacher)
63
+ expect(mockGoto).toHaveBeenCalledWith('/some/path')
64
+ })
65
+
66
+ // ── Invalid referrer ────────────────────────────────────────────────────────
67
+
68
+ it('ignores a referrer that does not start with /', () => {
69
+ mockPage.url.searchParams = new URLSearchParams({ referrer: 'https://evil.com' })
70
+ redirectAfterLogin(admin)
71
+ expect(mockGoto).toHaveBeenCalledWith('/admin')
72
+ })
73
+
74
+ it('ignores a protocol-relative referrer starting with //', () => {
75
+ mockPage.url.searchParams = new URLSearchParams({ referrer: '//evil.com' })
76
+ redirectAfterLogin(admin)
77
+ expect(mockGoto).toHaveBeenCalledWith('/admin')
78
+ })
79
+
80
+ it('ignores an empty referrer', () => {
81
+ mockPage.url.searchParams = new URLSearchParams({ referrer: '' })
82
+ redirectAfterLogin(student)
83
+ expect(mockGoto).toHaveBeenCalledWith('/')
84
+ })
85
+
86
+ // ── Single goto call ────────────────────────────────────────────────────────
87
+
88
+ it('calls goto exactly once', () => {
89
+ redirectAfterLogin(student)
90
+ expect(mockGoto).toHaveBeenCalledOnce()
91
+ })
92
+ })
@@ -0,0 +1,99 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3
+
4
+ vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
5
+ vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/dashboard') } }))
6
+ vi.mock('$lib/app-state.svelte', () => ({ appState: { user: undefined } }))
7
+
8
+ import { setupFetchInterceptor } from './fetch-interceptor'
9
+ import { goto } from '$app/navigation'
10
+ import { page } from '$app/state'
11
+ import { appState } from '$lib/app-state.svelte'
12
+
13
+ const mockGoto = vi.mocked(goto)
14
+ const mockPage = page as { url: URL }
15
+
16
+ function makeResponse(status: number) {
17
+ return new Response(null, { status })
18
+ }
19
+
20
+ describe('setupFetchInterceptor', () => {
21
+ let originalFetch: typeof fetch
22
+
23
+ beforeEach(() => {
24
+ // Save and restore window.fetch around each test
25
+ originalFetch = window.fetch
26
+ mockGoto.mockReset()
27
+ appState.user = { id: 1, role: 'admin', email: 'a@b.com', firstName: 'A', lastName: 'B', phone: '', optOut: false }
28
+ setupFetchInterceptor()
29
+ })
30
+
31
+ afterEach(() => {
32
+ window.fetch = originalFetch
33
+ appState.user = undefined
34
+ })
35
+
36
+ it('patches window.fetch', () => {
37
+ expect(window.fetch).not.toBe(originalFetch)
38
+ })
39
+
40
+ it('returns the response unchanged for non-401 status codes', async () => {
41
+ window.fetch = vi.fn().mockResolvedValue(makeResponse(200))
42
+ setupFetchInterceptor()
43
+
44
+ const res = await window.fetch('/api/data')
45
+
46
+ expect(res.status).toBe(200)
47
+ expect(mockGoto).not.toHaveBeenCalled()
48
+ })
49
+
50
+ it('does not redirect on 401 when no user is logged in', async () => {
51
+ appState.user = undefined
52
+ window.fetch = vi.fn().mockResolvedValue(makeResponse(401))
53
+ setupFetchInterceptor()
54
+
55
+ await window.fetch('/api/data')
56
+
57
+ expect(mockGoto).not.toHaveBeenCalled()
58
+ })
59
+
60
+ it('clears appState.user on 401 when a user is logged in', async () => {
61
+ window.fetch = vi.fn().mockResolvedValue(makeResponse(401))
62
+ setupFetchInterceptor()
63
+
64
+ await window.fetch('/api/data')
65
+
66
+ expect(appState.user).toBeUndefined()
67
+ })
68
+
69
+ it('redirects to /login with the current path as referrer on 401', async () => {
70
+ mockPage.url = new URL('http://localhost/dashboard?tab=profile')
71
+ window.fetch = vi.fn().mockResolvedValue(makeResponse(401))
72
+ setupFetchInterceptor()
73
+
74
+ await window.fetch('/api/data')
75
+
76
+ expect(mockGoto).toHaveBeenCalledWith(
77
+ '/login?referrer=' + encodeURIComponent('/dashboard?tab=profile')
78
+ )
79
+ })
80
+
81
+ it('still returns the 401 response to the caller', async () => {
82
+ window.fetch = vi.fn().mockResolvedValue(makeResponse(401))
83
+ setupFetchInterceptor()
84
+
85
+ const res = await window.fetch('/api/data')
86
+
87
+ expect(res.status).toBe(401)
88
+ })
89
+
90
+ it('passes all fetch arguments through to the original fetch', async () => {
91
+ const innerFetch = vi.fn().mockResolvedValue(makeResponse(200))
92
+ window.fetch = innerFetch
93
+ setupFetchInterceptor()
94
+
95
+ await window.fetch('/api/endpoint', { method: 'POST', body: 'data' })
96
+
97
+ expect(innerFetch).toHaveBeenCalledWith('/api/endpoint', { method: 'POST', body: 'data' })
98
+ })
99
+ })
@@ -0,0 +1,91 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import { focusOnFirstError } from './focus'
4
+
5
+ function makeInput(valid: boolean): HTMLInputElement {
6
+ const input = document.createElement('input')
7
+ input.type = 'text'
8
+ if (!valid) {
9
+ input.required = true
10
+ // leave value empty so checkValidity() returns false
11
+ }
12
+ input.focus = vi.fn()
13
+ return input
14
+ }
15
+
16
+ function makeForm(...inputs: HTMLInputElement[]): HTMLFormElement {
17
+ const form = document.createElement('form')
18
+ for (const input of inputs) form.appendChild(input)
19
+ return form
20
+ }
21
+
22
+ describe('focusOnFirstError', () => {
23
+ it('focuses the first invalid input', () => {
24
+ const invalid = makeInput(false)
25
+ const form = makeForm(invalid)
26
+
27
+ focusOnFirstError(form)
28
+
29
+ expect(invalid.focus).toHaveBeenCalledOnce()
30
+ })
31
+
32
+ it('does not focus a valid input', () => {
33
+ const valid = makeInput(true)
34
+ const form = makeForm(valid)
35
+
36
+ focusOnFirstError(form)
37
+
38
+ expect(valid.focus).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it('focuses only the first invalid input when multiple are invalid', () => {
42
+ const first = makeInput(false)
43
+ const second = makeInput(false)
44
+ const form = makeForm(first, second)
45
+
46
+ focusOnFirstError(form)
47
+
48
+ expect(first.focus).toHaveBeenCalledOnce()
49
+ expect(second.focus).not.toHaveBeenCalled()
50
+ })
51
+
52
+ it('skips valid inputs before the first invalid one', () => {
53
+ const valid = makeInput(true)
54
+ const invalid = makeInput(false)
55
+ const form = makeForm(valid, invalid)
56
+
57
+ focusOnFirstError(form)
58
+
59
+ expect(valid.focus).not.toHaveBeenCalled()
60
+ expect(invalid.focus).toHaveBeenCalledOnce()
61
+ })
62
+
63
+ it('does nothing when the form has no inputs', () => {
64
+ const form = document.createElement('form')
65
+ // Should not throw
66
+ expect(() => focusOnFirstError(form)).not.toThrow()
67
+ })
68
+
69
+ it('does nothing when all inputs are valid', () => {
70
+ const a = makeInput(true)
71
+ const b = makeInput(true)
72
+ const form = makeForm(a, b)
73
+
74
+ focusOnFirstError(form)
75
+
76
+ expect(a.focus).not.toHaveBeenCalled()
77
+ expect(b.focus).not.toHaveBeenCalled()
78
+ })
79
+
80
+ it('ignores non-input elements (e.g. select, button)', () => {
81
+ const select = document.createElement('select')
82
+ select.required = true
83
+ const focusSpy = vi.spyOn(select, 'focus')
84
+ const form = document.createElement('form')
85
+ form.appendChild(select)
86
+
87
+ focusOnFirstError(form)
88
+
89
+ expect(focusSpy).not.toHaveBeenCalled()
90
+ })
91
+ })
package/src/lib/google.ts CHANGED
@@ -2,6 +2,26 @@ import { PUBLIC_GOOGLE_CLIENT_ID } from '$env/static/public'
2
2
  import { appState } from '$lib/app-state.svelte'
3
3
  import { redirectAfterLogin } from '$lib/auth-redirect'
4
4
 
5
+ /**
6
+ * Waits for the Google Identity Services SDK to load, then calls `fn`.
7
+ * Polls every 50 ms; gives up after 10 seconds.
8
+ */
9
+ function whenGoogleReady(fn: () => void) {
10
+ if (typeof google !== 'undefined') {
11
+ fn()
12
+ return
13
+ }
14
+ const deadline = Date.now() + 10_000
15
+ const id = setInterval(() => {
16
+ if (typeof google !== 'undefined') {
17
+ clearInterval(id)
18
+ fn()
19
+ } else if (Date.now() > deadline) {
20
+ clearInterval(id)
21
+ }
22
+ }, 50)
23
+ }
24
+
5
25
  /**
6
26
  * Renders the Google Sign-In button inside the element with id `googleButton`.
7
27
  *
@@ -10,16 +30,18 @@ import { redirectAfterLogin } from '$lib/auth-redirect'
10
30
  * correctly within its container.
11
31
  */
12
32
  export function renderGoogleButton() {
13
- const btn = document.getElementById('googleButton')
14
- if (btn) {
15
- const width = btn.offsetWidth || btn.parentElement?.offsetWidth || 400
16
- google.accounts.id.renderButton(btn, {
17
- type: 'standard',
18
- theme: 'outline',
19
- size: 'large',
20
- width: Math.floor(width)
21
- })
22
- }
33
+ whenGoogleReady(() => {
34
+ const btn = document.getElementById('googleButton')
35
+ if (btn) {
36
+ const width = btn.offsetWidth || btn.parentElement?.offsetWidth || 400
37
+ google.accounts.id.renderButton(btn, {
38
+ type: 'standard',
39
+ theme: 'outline',
40
+ size: 'large',
41
+ width: Math.floor(width)
42
+ })
43
+ }
44
+ })
23
45
  }
24
46
 
25
47
  /**
@@ -33,13 +55,15 @@ export function renderGoogleButton() {
33
55
  * 3. Redirects the user via {@link redirectAfterLogin}.
34
56
  */
35
57
  export function initializeGoogleAccounts() {
36
- if (!appState.googleInitialized) {
37
- google.accounts.id.initialize({
38
- client_id: PUBLIC_GOOGLE_CLIENT_ID,
39
- callback: googleCallback
40
- })
41
- appState.googleInitialized = true
42
- }
58
+ whenGoogleReady(() => {
59
+ if (!appState.googleInitialized) {
60
+ google.accounts.id.initialize({
61
+ client_id: PUBLIC_GOOGLE_CLIENT_ID,
62
+ callback: googleCallback
63
+ })
64
+ appState.googleInitialized = true
65
+ }
66
+ })
43
67
 
44
68
  async function googleCallback(response: google.accounts.id.CredentialResponse) {
45
69
  const res = await fetch('/auth/google', {
@@ -0,0 +1,189 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3
+
4
+ vi.mock('$env/static/public', () => ({ PUBLIC_GOOGLE_CLIENT_ID: 'test-client-id' }))
5
+ vi.mock('$lib/app-state.svelte', () => ({ appState: { googleInitialized: false, user: undefined } }))
6
+ vi.mock('$lib/auth-redirect', () => ({ redirectAfterLogin: vi.fn() }))
7
+
8
+ import { renderGoogleButton, initializeGoogleAccounts } from './google'
9
+ import { appState } from '$lib/app-state.svelte'
10
+ import { redirectAfterLogin } from '$lib/auth-redirect'
11
+
12
+ const mockRedirectAfterLogin = vi.mocked(redirectAfterLogin)
13
+
14
+ /** Build a minimal google.accounts.id mock and attach it to globalThis. */
15
+ function installGoogleMock() {
16
+ const mock = {
17
+ initialize: vi.fn(),
18
+ renderButton: vi.fn()
19
+ }
20
+ ;(globalThis as unknown as Record<string, unknown>).google = {
21
+ accounts: { id: mock }
22
+ }
23
+ return mock
24
+ }
25
+
26
+ function removeGoogleMock() {
27
+ delete (globalThis as unknown as Record<string, unknown>).google
28
+ }
29
+
30
+ describe('renderGoogleButton', () => {
31
+ beforeEach(() => {
32
+ document.body.innerHTML = ''
33
+ appState.googleInitialized = false
34
+ })
35
+
36
+ afterEach(() => {
37
+ removeGoogleMock()
38
+ vi.useRealTimers()
39
+ })
40
+
41
+ it('calls google.accounts.id.renderButton with the button element', () => {
42
+ const mock = installGoogleMock()
43
+ const btn = document.createElement('div')
44
+ btn.id = 'googleButton'
45
+ document.body.appendChild(btn)
46
+
47
+ renderGoogleButton()
48
+
49
+ expect(mock.renderButton).toHaveBeenCalledOnce()
50
+ expect(mock.renderButton).toHaveBeenCalledWith(btn, expect.objectContaining({
51
+ type: 'standard',
52
+ theme: 'outline',
53
+ size: 'large'
54
+ }))
55
+ })
56
+
57
+ it('falls back to 400 when the button has no width', () => {
58
+ const mock = installGoogleMock()
59
+ const btn = document.createElement('div')
60
+ btn.id = 'googleButton'
61
+ document.body.appendChild(btn)
62
+
63
+ renderGoogleButton()
64
+
65
+ const [, opts] = mock.renderButton.mock.calls[0]
66
+ expect(opts.width).toBe(400)
67
+ })
68
+
69
+ it('does nothing when the googleButton element is absent', () => {
70
+ const mock = installGoogleMock()
71
+
72
+ renderGoogleButton()
73
+
74
+ expect(mock.renderButton).not.toHaveBeenCalled()
75
+ })
76
+
77
+ it('polls until google is available, then renders', () => {
78
+ vi.useFakeTimers()
79
+ const btn = document.createElement('div')
80
+ btn.id = 'googleButton'
81
+ document.body.appendChild(btn)
82
+
83
+ renderGoogleButton() // google not yet defined
84
+
85
+ const mock = installGoogleMock()
86
+ vi.advanceTimersByTime(100) // advance past one poll interval
87
+
88
+ expect(mock.renderButton).toHaveBeenCalledOnce()
89
+ })
90
+
91
+ it('gives up polling after 10 seconds without throwing', () => {
92
+ vi.useFakeTimers()
93
+ // google is never installed
94
+
95
+ expect(() => {
96
+ renderGoogleButton()
97
+ vi.advanceTimersByTime(11_000)
98
+ }).not.toThrow()
99
+ })
100
+ })
101
+
102
+ describe('initializeGoogleAccounts', () => {
103
+ beforeEach(() => {
104
+ appState.googleInitialized = false
105
+ appState.user = undefined
106
+ mockRedirectAfterLogin.mockReset()
107
+ document.body.innerHTML = ''
108
+ })
109
+
110
+ afterEach(() => {
111
+ removeGoogleMock()
112
+ vi.useRealTimers()
113
+ })
114
+
115
+ it('calls google.accounts.id.initialize with the client id', () => {
116
+ const mock = installGoogleMock()
117
+
118
+ initializeGoogleAccounts()
119
+
120
+ expect(mock.initialize).toHaveBeenCalledOnce()
121
+ expect(mock.initialize).toHaveBeenCalledWith(expect.objectContaining({
122
+ client_id: 'test-client-id'
123
+ }))
124
+ })
125
+
126
+ it('sets appState.googleInitialized to true', () => {
127
+ installGoogleMock()
128
+
129
+ initializeGoogleAccounts()
130
+
131
+ expect(appState.googleInitialized).toBe(true)
132
+ })
133
+
134
+ it('does not call initialize a second time if already initialized', () => {
135
+ const mock = installGoogleMock()
136
+ appState.googleInitialized = true
137
+
138
+ initializeGoogleAccounts()
139
+
140
+ expect(mock.initialize).not.toHaveBeenCalled()
141
+ })
142
+
143
+ it('callback POSTs the credential token to /auth/google and updates appState', async () => {
144
+ const mock = installGoogleMock()
145
+ const user = { id: 1, role: 'admin' }
146
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
147
+ new Response(JSON.stringify({ user }), { status: 200 })
148
+ )
149
+
150
+ initializeGoogleAccounts()
151
+
152
+ // Extract and invoke the registered callback
153
+ const { callback } = mock.initialize.mock.calls[0][0]
154
+ await callback({ credential: 'test-credential' })
155
+
156
+ expect(fetch).toHaveBeenCalledWith('/auth/google', expect.objectContaining({
157
+ method: 'POST',
158
+ body: JSON.stringify({ token: 'test-credential' })
159
+ }))
160
+ expect(appState.user).toEqual(user)
161
+ expect(mockRedirectAfterLogin).toHaveBeenCalledWith(user)
162
+ })
163
+
164
+ it('callback does not update state when the response is not ok', async () => {
165
+ const mock = installGoogleMock()
166
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
167
+ new Response(null, { status: 401 })
168
+ )
169
+
170
+ initializeGoogleAccounts()
171
+
172
+ const { callback } = mock.initialize.mock.calls[0][0]
173
+ await callback({ credential: 'bad-credential' })
174
+
175
+ expect(appState.user).toBeUndefined()
176
+ expect(mockRedirectAfterLogin).not.toHaveBeenCalled()
177
+ })
178
+
179
+ it('polls until google is available, then initializes', () => {
180
+ vi.useFakeTimers()
181
+
182
+ initializeGoogleAccounts() // google not yet defined
183
+
184
+ const mock = installGoogleMock()
185
+ vi.advanceTimersByTime(100)
186
+
187
+ expect(mock.initialize).toHaveBeenCalledOnce()
188
+ })
189
+ })