sveltekit-auth-example 5.8.2 → 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 (40) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +11 -11
  3. package/db_create.sql +0 -1
  4. package/package.json +3 -1
  5. package/src/hooks.server.unit.test.ts +163 -0
  6. package/src/lib/app-state.svelte.unit.test.ts +73 -0
  7. package/src/lib/auth-redirect.unit.test.ts +92 -0
  8. package/src/lib/fetch-interceptor.unit.test.ts +99 -0
  9. package/src/lib/focus.unit.test.ts +91 -0
  10. package/src/lib/google.unit.test.ts +189 -0
  11. package/src/lib/server/brevo.ts +3 -3
  12. package/src/lib/server/brevo.unit.test.ts +186 -0
  13. package/src/lib/server/db.unit.test.ts +91 -0
  14. package/src/lib/server/email/mfa-code.ts +2 -2
  15. package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
  16. package/src/lib/server/email/password-reset.ts +3 -3
  17. package/src/lib/server/email/password-reset.unit.test.ts +70 -0
  18. package/src/lib/server/email/verify-email.ts +3 -3
  19. package/src/lib/server/email/verify-email.unit.test.ts +79 -0
  20. package/src/lib/server/turnstile.ts +2 -2
  21. package/src/lib/server/turnstile.unit.test.ts +111 -0
  22. package/src/routes/api/v1/user/+server.unit.test.ts +114 -0
  23. package/src/routes/auth/[slug]/+server.unit.test.ts +15 -0
  24. package/src/routes/auth/forgot/+server.ts +2 -2
  25. package/src/routes/auth/forgot/+server.unit.test.ts +110 -0
  26. package/src/routes/auth/google/+server.unit.test.ts +132 -0
  27. package/src/routes/auth/login/+server.ts +2 -2
  28. package/src/routes/auth/login/+server.unit.test.ts +221 -0
  29. package/src/routes/auth/logout/+server.unit.test.ts +85 -0
  30. package/src/routes/auth/mfa/+server.ts +2 -2
  31. package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
  32. package/src/routes/auth/register/+server.ts +9 -3
  33. package/src/routes/auth/register/+server.unit.test.ts +182 -0
  34. package/src/routes/auth/reset/+server.ts +5 -4
  35. package/src/routes/auth/reset/+server.unit.test.ts +139 -0
  36. package/src/routes/auth/verify/[token]/+server.ts +2 -2
  37. package/src/routes/auth/verify/[token]/+server.unit.test.ts +124 -0
  38. package/src/routes/login/+page.svelte +5 -1
  39. package/src/service-worker.unit.test.ts +228 -0
  40. package/vite.config.ts +5 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  - Add password complexity checking on /register and /profile pages (only checks for length currently despite what the pages say)
4
4
 
5
+ # 5.8.3
6
+
7
+ - Add unit tests for all auth route handlers: `/auth/[slug]`, `/auth/forgot`, `/auth/google`, `/auth/login`, `/auth/logout`, `/auth/mfa`, `/auth/register`, `/auth/reset`, `/auth/verify/[token]`
8
+ - Switch all server-side env imports from `$env/static/private` to `$env/dynamic/private` so secrets are read at runtime rather than baked into the build
9
+
5
10
  # 5.8.2
6
11
 
7
12
  - Add Chrome DevTools
package/README.md CHANGED
@@ -8,15 +8,15 @@ A complete, production-ready authentication and authorization starter for **Svel
8
8
 
9
9
  ## Features
10
10
 
11
- | | |
12
- | ---------------------------------------------- | --------------------------------------- |
13
- | ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
14
- | ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
15
- | ✅ Forgot password / email reset (Brevo) | ✅ User profile management |
16
- | ✅ Session management + timeout | ✅ Rate limiting |
17
- | ✅ Role-based access control | ✅ Password complexity enforcement |
18
- | ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
19
- | ✅ Cloudflare Turnstile CAPTCHA (bot protection) | |
11
+ | | |
12
+ | ------------------------------------------------ | --------------------------------------- |
13
+ | ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
14
+ | ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
15
+ | ✅ Forgot password / email reset (Brevo) | ✅ User profile management |
16
+ | ✅ Session management + timeout | ✅ Rate limiting |
17
+ | ✅ Role-based access control | ✅ Password complexity enforcement |
18
+ | ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
19
+ | ✅ Cloudflare Turnstile CAPTCHA (bot protection) | |
20
20
 
21
21
  ## Stack
22
22
 
@@ -88,8 +88,8 @@ yarn preview
88
88
 
89
89
  The db_create.sql script adds three users to the database with obvious roles:
90
90
 
91
- | Email | Password | Role |
92
- | ------------------- | ---------- | ------- |
91
+ | Email | Password | Role |
92
+ | ------------------- | ------------ | ------- |
93
93
  | admin@example.com | Admin1234! | admin |
94
94
  | teacher@example.com | Teacher1234! | teacher |
95
95
  | student@example.com | Student1234! | student |
package/db_create.sql CHANGED
@@ -1,6 +1,5 @@
1
1
  -- Run via db_create.sh or:
2
2
  -- $ psql -d postgres -f db_create.sql && psql -d auth -f db_schema.sql
3
-
4
3
  -- Create role if not already there
5
4
  DO $do$
6
5
  BEGIN
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sveltekit-auth-example",
3
3
  "description": "SvelteKit Authentication Example",
4
- "version": "5.8.2",
4
+ "version": "5.8.3",
5
5
  "author": "Nate Stuyvesant",
6
6
  "license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
7
7
  "repository": {
@@ -55,10 +55,12 @@
55
55
  "@types/google.accounts": "^0.0.18",
56
56
  "@types/jsonwebtoken": "^9.0.10",
57
57
  "@types/pg": "^8.18.0",
58
+ "@vitest/coverage-v8": "4.1.0",
58
59
  "eslint": "^10.0.3",
59
60
  "eslint-config-prettier": "^10.1.8",
60
61
  "eslint-plugin-svelte": "^3.15.2",
61
62
  "globals": "^17.4.0",
63
+ "jsdom": "^28.1.0",
62
64
  "playwright": "^1.58.2",
63
65
  "prettier": "^3.8.1",
64
66
  "prettier-plugin-sql": "^0.19.2",
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { error } from '@sveltejs/kit'
3
+
4
+ vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
5
+
6
+ import { handle } from './hooks.server'
7
+ import { query } from '$lib/server/db'
8
+
9
+ const mockQuery = vi.mocked(query)
10
+
11
+ /** Build a minimal event object for the hook. */
12
+ function makeEvent({
13
+ pathname = '/page',
14
+ sessionCookie = undefined as string | undefined,
15
+ ip = '1.2.3.4'
16
+ } = {}) {
17
+ const cookieStore = new Map<string, string>()
18
+ if (sessionCookie) cookieStore.set('session', sessionCookie)
19
+
20
+ return {
21
+ url: new URL(`http://localhost${pathname}`),
22
+ cookies: {
23
+ get: (name: string) => cookieStore.get(name),
24
+ delete: vi.fn()
25
+ },
26
+ locals: {} as Record<string, unknown>,
27
+ getClientAddress: () => ip
28
+ } as unknown as Parameters<typeof handle>[0]['event']
29
+ }
30
+
31
+ const resolve = vi.fn(async (event: unknown) => new Response('ok', { status: 200 }))
32
+
33
+ beforeEach(() => {
34
+ vi.resetAllMocks()
35
+ resolve.mockResolvedValue(new Response('ok', { status: 200 }))
36
+ })
37
+
38
+ // ── Static asset bypass ────────────────────────────────────────────────────────
39
+
40
+ describe('static asset bypass', () => {
41
+ it('calls resolve immediately for /_app/ paths without touching the db', async () => {
42
+ const event = makeEvent({ pathname: '/_app/immutable/app.js' })
43
+
44
+ await handle({ event, resolve })
45
+
46
+ expect(resolve).toHaveBeenCalledOnce()
47
+ expect(mockQuery).not.toHaveBeenCalled()
48
+ })
49
+ })
50
+
51
+ // ── Rate limiting ──────────────────────────────────────────────────────────────
52
+
53
+ describe('rate limiting', () => {
54
+ it('allows requests under the limit', async () => {
55
+ const event = makeEvent({ pathname: '/auth/login', ip: '10.0.0.1' })
56
+
57
+ await expect(handle({ event, resolve })).resolves.not.toThrow()
58
+ })
59
+
60
+ it('throws 429 after exceeding the request limit for a rate-limited path', async () => {
61
+ const ip = '10.0.0.99'
62
+
63
+ // Exhaust the 20-request allowance
64
+ for (let i = 0; i < 20; i++) {
65
+ await handle({ event: makeEvent({ pathname: '/auth/login', ip }), resolve }).catch(() => {})
66
+ }
67
+
68
+ await expect(
69
+ handle({ event: makeEvent({ pathname: '/auth/login', ip }), resolve })
70
+ ).rejects.toMatchObject({ status: 429 })
71
+ })
72
+
73
+ it('does not rate-limit non-auth paths', async () => {
74
+ const ip = '10.0.0.2'
75
+
76
+ for (let i = 0; i < 25; i++) {
77
+ await handle({ event: makeEvent({ pathname: '/about', ip }), resolve })
78
+ }
79
+
80
+ expect(resolve).toHaveBeenCalledTimes(25)
81
+ })
82
+ })
83
+
84
+ // ── Session handling ───────────────────────────────────────────────────────────
85
+
86
+ describe('session handling', () => {
87
+ it('attaches user to locals when a valid session cookie is present', async () => {
88
+ const user = { id: 1, role: 'admin' }
89
+ mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: user }] } as any)
90
+
91
+ const event = makeEvent({ sessionCookie: 'valid-session-uuid' })
92
+
93
+ await handle({ event, resolve })
94
+
95
+ expect(event.locals.user).toEqual(user)
96
+ })
97
+
98
+ it('does not call query when no session cookie is present', async () => {
99
+ const event = makeEvent()
100
+
101
+ await handle({ event, resolve })
102
+
103
+ expect(mockQuery).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('deletes the session cookie when the session is not found in the db', async () => {
107
+ mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: undefined }] } as any)
108
+
109
+ const event = makeEvent({ sessionCookie: 'stale-session-uuid' })
110
+
111
+ await handle({ event, resolve })
112
+
113
+ expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
114
+ })
115
+
116
+ it('deletes the session cookie when no session cookie is present and locals.user is unset', async () => {
117
+ const event = makeEvent()
118
+
119
+ await handle({ event, resolve })
120
+
121
+ expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
122
+ })
123
+
124
+ it('does not delete the session cookie when a valid session exists', async () => {
125
+ const user = { id: 2, role: 'student' }
126
+ mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: user }] } as any)
127
+
128
+ const event = makeEvent({ sessionCookie: 'valid-session-uuid' })
129
+
130
+ await handle({ event, resolve })
131
+
132
+ expect(event.cookies.delete).not.toHaveBeenCalled()
133
+ })
134
+
135
+ it('uses the named prepared statement for session lookup', async () => {
136
+ mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: { id: 3 } }] } as any)
137
+
138
+ const event = makeEvent({ sessionCookie: 'some-uuid' })
139
+
140
+ await handle({ event, resolve })
141
+
142
+ expect(mockQuery).toHaveBeenCalledWith(
143
+ expect.stringContaining('get_and_update_session'),
144
+ ['some-uuid'],
145
+ 'get-and-update-session'
146
+ )
147
+ })
148
+ })
149
+
150
+ // ── Response pass-through ──────────────────────────────────────────────────────
151
+
152
+ describe('response', () => {
153
+ it('returns the response from resolve', async () => {
154
+ const fakeResponse = new Response('hello', { status: 200 })
155
+ resolve.mockResolvedValue(fakeResponse)
156
+
157
+ const event = makeEvent()
158
+
159
+ const result = await handle({ event, resolve })
160
+
161
+ expect(result).toBe(fakeResponse)
162
+ })
163
+ })
@@ -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
+ })