Skip to content

Commit 1633f44

Browse files
committed
increased test coverage
1 parent cdb2613 commit 1633f44

24 files changed

+2751
-296
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Injector } from '@furystack/inject'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
import { InstallApiClient } from './api-clients/install-api-client.js'
5+
import { InstallService } from './install-service.js'
6+
7+
const createMockApiClient = () => ({
8+
call: vi.fn().mockResolvedValue({ result: { state: 'needsInstall' } }),
9+
})
10+
11+
describe('InstallService', () => {
12+
let injector: Injector
13+
let mockApi: ReturnType<typeof createMockApiClient>
14+
let service: InstallService
15+
16+
beforeEach(() => {
17+
injector = new Injector()
18+
mockApi = createMockApiClient()
19+
injector.setExplicitInstance(mockApi as unknown as InstallApiClient, InstallApiClient)
20+
service = injector.getInstance(InstallService)
21+
})
22+
23+
afterEach(() => {
24+
service[Symbol.dispose]()
25+
})
26+
27+
it('should fetch service status from the API', async () => {
28+
const result = await service.getServiceStatus()
29+
expect(result).toEqual({ state: 'needsInstall' })
30+
expect(mockApi.call).toHaveBeenCalledWith({
31+
method: 'GET',
32+
action: '/serviceStatus',
33+
})
34+
})
35+
36+
it('should cache the result on subsequent calls', async () => {
37+
await service.getServiceStatus()
38+
await service.getServiceStatus()
39+
expect(mockApi.call).toHaveBeenCalledTimes(1)
40+
})
41+
42+
it('should expose getServiceStatusAsObservable', () => {
43+
expect(typeof service.getServiceStatusAsObservable).toBe('function')
44+
})
45+
46+
it('should dispose without throwing', () => {
47+
expect(() => service[Symbol.dispose]()).not.toThrow()
48+
})
49+
})
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { Injector } from '@furystack/inject'
2+
import { NotyService } from '@furystack/shades-common-components'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { IdentityApiClient } from './api-clients/identity-api-client.js'
6+
import { SessionService } from './session.js'
7+
8+
const createMocks = () => ({
9+
api: { call: vi.fn() },
10+
notys: { emit: vi.fn() },
11+
})
12+
13+
const createService = (mocks: ReturnType<typeof createMocks>): { injector: Injector; service: SessionService } => {
14+
const injector = new Injector()
15+
injector.setExplicitInstance(mocks.api as unknown as IdentityApiClient, IdentityApiClient)
16+
injector.setExplicitInstance(mocks.notys as unknown as NotyService, NotyService)
17+
const service = injector.getInstance(SessionService)
18+
return { injector, service }
19+
}
20+
21+
describe('SessionService', () => {
22+
let mocks: ReturnType<typeof createMocks>
23+
let service: SessionService
24+
25+
beforeEach(() => {
26+
mocks = createMocks()
27+
mocks.api.call.mockResolvedValue({ result: { isAuthenticated: false } })
28+
;({ service } = createService(mocks))
29+
})
30+
31+
afterEach(() => {
32+
service[Symbol.dispose]()
33+
})
34+
35+
it('should initialize with empty loginError', () => {
36+
expect(service.loginError.getValue()).toBe('')
37+
})
38+
39+
describe('isAuthenticated', () => {
40+
it('should return true when state is authenticated', async () => {
41+
service.state.setValue('authenticated')
42+
await expect(service.isAuthenticated()).resolves.toBe(true)
43+
})
44+
45+
it('should return false when state is unauthenticated', async () => {
46+
service.state.setValue('unauthenticated')
47+
await expect(service.isAuthenticated()).resolves.toBe(false)
48+
})
49+
50+
it('should return false when state is initializing', async () => {
51+
service.state.setValue('initializing')
52+
await expect(service.isAuthenticated()).resolves.toBe(false)
53+
})
54+
55+
it('should return false when state is offline', async () => {
56+
service.state.setValue('offline')
57+
await expect(service.isAuthenticated()).resolves.toBe(false)
58+
})
59+
})
60+
61+
describe('isAuthorized', () => {
62+
it('should return true when user has all requested roles', async () => {
63+
service.currentUser.setValue({ username: 'test', roles: ['admin', 'editor'] })
64+
service.state.setValue('authenticated')
65+
await expect(service.isAuthorized('admin', 'editor')).resolves.toBe(true)
66+
})
67+
68+
it('should return false when user is missing a role', async () => {
69+
service.currentUser.setValue({ username: 'test', roles: ['editor'] })
70+
service.state.setValue('authenticated')
71+
await expect(service.isAuthorized('admin')).resolves.toBe(false)
72+
})
73+
74+
it('should throw when there is no current user', async () => {
75+
service.currentUser.setValue(null)
76+
await expect(service.isAuthorized('admin')).rejects.toThrow('No user available')
77+
})
78+
79+
it('should return true when no roles are requested', async () => {
80+
service.currentUser.setValue({ username: 'test', roles: [] })
81+
service.state.setValue('authenticated')
82+
await expect(service.isAuthorized()).resolves.toBe(true)
83+
})
84+
})
85+
86+
describe('getCurrentUser', () => {
87+
it('should return the current user when set', async () => {
88+
const user = { username: 'test', roles: ['admin'] }
89+
service.currentUser.setValue(user)
90+
await expect(service.getCurrentUser()).resolves.toEqual(user)
91+
})
92+
93+
it('should throw when no user is available', async () => {
94+
service.currentUser.setValue(null)
95+
await expect(service.getCurrentUser()).rejects.toThrow('No user available')
96+
})
97+
98+
it('should emit a notification when no user is available', async () => {
99+
service.currentUser.setValue(null)
100+
try {
101+
await service.getCurrentUser()
102+
} catch {
103+
// expected
104+
}
105+
expect(mocks.notys.emit).toHaveBeenCalledWith('onNotyAdded', expect.objectContaining({ type: 'warning' }))
106+
})
107+
})
108+
109+
describe('init', () => {
110+
it('should set state to authenticated and fetch user', async () => {
111+
const user = { username: 'test', roles: [] }
112+
const m = createMocks()
113+
m.api.call.mockResolvedValueOnce({ result: { isAuthenticated: true } }).mockResolvedValueOnce({ result: user })
114+
115+
const { service: svc } = createService(m)
116+
117+
await vi.waitFor(() => {
118+
expect(svc.state.getValue()).toBe('authenticated')
119+
})
120+
expect(svc.currentUser.getValue()).toEqual(user)
121+
svc[Symbol.dispose]()
122+
})
123+
124+
it('should set state to unauthenticated when not logged in', async () => {
125+
const m = createMocks()
126+
m.api.call.mockResolvedValueOnce({ result: { isAuthenticated: false } })
127+
128+
const { service: svc } = createService(m)
129+
130+
await vi.waitFor(() => {
131+
expect(svc.state.getValue()).toBe('unauthenticated')
132+
})
133+
expect(svc.currentUser.getValue()).toBeNull()
134+
svc[Symbol.dispose]()
135+
})
136+
137+
it('should set state to offline when API call fails', async () => {
138+
const m = createMocks()
139+
m.api.call.mockRejectedValueOnce(new Error('Network error'))
140+
141+
const { service: svc } = createService(m)
142+
143+
await vi.waitFor(() => {
144+
expect(svc.state.getValue()).toBe('offline')
145+
})
146+
svc[Symbol.dispose]()
147+
})
148+
149+
it('should only initialize once on repeated init calls', async () => {
150+
const m = createMocks()
151+
m.api.call.mockResolvedValue({ result: { isAuthenticated: false } })
152+
153+
const { service: svc } = createService(m)
154+
155+
await vi.waitFor(() => {
156+
expect(svc.state.getValue()).toBe('unauthenticated')
157+
})
158+
159+
await svc.init()
160+
expect(m.api.call).toHaveBeenCalledTimes(1)
161+
svc[Symbol.dispose]()
162+
})
163+
})
164+
165+
describe('login', () => {
166+
it('should set user and state on successful login', async () => {
167+
const user = { username: 'test', roles: ['admin'] }
168+
mocks.api.call.mockResolvedValueOnce({ result: user })
169+
170+
await service.login('test', 'password')
171+
172+
expect(service.currentUser.getValue()).toEqual(user)
173+
expect(service.state.getValue()).toBe('authenticated')
174+
})
175+
176+
it('should emit a success notification on login', async () => {
177+
mocks.api.call.mockResolvedValueOnce({ result: { username: 'test', roles: [] } })
178+
179+
await service.login('test', 'password')
180+
181+
expect(mocks.notys.emit).toHaveBeenCalledWith('onNotyAdded', expect.objectContaining({ type: 'success' }))
182+
})
183+
184+
it('should set loginError on failed login', async () => {
185+
mocks.api.call.mockRejectedValueOnce(new Error('Invalid credentials'))
186+
187+
await service.login('test', 'wrong')
188+
189+
expect(service.loginError.getValue()).toBe('Invalid credentials')
190+
})
191+
192+
it('should emit a warning notification on failed login', async () => {
193+
mocks.api.call.mockRejectedValueOnce(new Error('Invalid credentials'))
194+
195+
await service.login('test', 'wrong')
196+
197+
expect(mocks.notys.emit).toHaveBeenCalledWith('onNotyAdded', expect.objectContaining({ type: 'warning' }))
198+
})
199+
200+
it('should set loginError to empty string for non-Error rejections', async () => {
201+
mocks.api.call.mockRejectedValueOnce('some string error')
202+
203+
await service.login('test', 'wrong')
204+
205+
expect(service.loginError.getValue()).toBe('')
206+
})
207+
208+
it('should set isOperationInProgress to false after login completes', async () => {
209+
mocks.api.call.mockResolvedValueOnce({ result: { username: 'test', roles: [] } })
210+
211+
await service.login('test', 'password')
212+
213+
expect(service.isOperationInProgress.getValue()).toBe(false)
214+
})
215+
})
216+
217+
describe('logout', () => {
218+
it('should clear user and set state to unauthenticated', async () => {
219+
service.currentUser.setValue({ username: 'test', roles: [] })
220+
service.state.setValue('authenticated')
221+
mocks.api.call.mockResolvedValueOnce({})
222+
223+
await service.logout()
224+
225+
expect(service.currentUser.getValue()).toBeNull()
226+
expect(service.state.getValue()).toBe('unauthenticated')
227+
})
228+
229+
it('should emit an info notification', async () => {
230+
mocks.api.call.mockResolvedValueOnce({})
231+
232+
await service.logout()
233+
234+
expect(mocks.notys.emit).toHaveBeenCalledWith('onNotyAdded', expect.objectContaining({ type: 'info' }))
235+
})
236+
237+
it('should set isOperationInProgress to false after logout completes', async () => {
238+
mocks.api.call.mockResolvedValueOnce({})
239+
240+
await service.logout()
241+
242+
expect(service.isOperationInProgress.getValue()).toBe(false)
243+
})
244+
})
245+
246+
describe('Symbol.dispose', () => {
247+
it('should dispose all observables without throwing', () => {
248+
expect(() => service[Symbol.dispose]()).not.toThrow()
249+
})
250+
})
251+
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { ThemeProviderService } from '@furystack/shades-common-components'
2+
import { describe, expect, it, vi } from 'vitest'
3+
4+
import { applyTheme, DEFAULT_THEME_KEY, THEME_STORAGE_KEY, themeEntries } from './theme-registry.js'
5+
6+
describe('theme-registry', () => {
7+
describe('constants', () => {
8+
it('should export THEME_STORAGE_KEY', () => {
9+
expect(THEME_STORAGE_KEY).toBe('stackcraft-theme')
10+
})
11+
12+
it('should export DEFAULT_THEME_KEY', () => {
13+
expect(DEFAULT_THEME_KEY).toBe('dark')
14+
})
15+
})
16+
17+
describe('themeEntries', () => {
18+
it('should include default dark and light themes', () => {
19+
const keys = themeEntries.map((e) => e.key)
20+
expect(keys).toContain('dark')
21+
expect(keys).toContain('light')
22+
})
23+
24+
it('should have unique keys', () => {
25+
const keys = themeEntries.map((e) => e.key)
26+
expect(new Set(keys).size).toBe(keys.length)
27+
})
28+
29+
it('should have a label and loader for every entry', () => {
30+
for (const entry of themeEntries) {
31+
expect(entry.label).toBeTruthy()
32+
expect(typeof entry.loader).toBe('function')
33+
}
34+
})
35+
36+
it('should have dark and light as the first two entries', () => {
37+
expect(themeEntries[0].key).toBe('dark')
38+
expect(themeEntries[1].key).toBe('light')
39+
})
40+
41+
it('should have quotes for special themes but not default themes', () => {
42+
const dark = themeEntries.find((e) => e.key === 'dark')
43+
const light = themeEntries.find((e) => e.key === 'light')
44+
expect(dark?.quote).toBeUndefined()
45+
expect(light?.quote).toBeUndefined()
46+
47+
const special = themeEntries.filter((e) => e.key !== 'dark' && e.key !== 'light')
48+
for (const entry of special) {
49+
expect(entry.quote).toBeTruthy()
50+
}
51+
})
52+
})
53+
54+
describe('applyTheme', () => {
55+
it('should call setAssignedTheme with the loaded theme for a valid key', async () => {
56+
const mockProvider = { setAssignedTheme: vi.fn() } as unknown as ThemeProviderService
57+
58+
await applyTheme('dark', mockProvider)
59+
60+
expect(mockProvider.setAssignedTheme).toHaveBeenCalledOnce()
61+
expect(mockProvider.setAssignedTheme).toHaveBeenCalledWith(expect.objectContaining({}))
62+
})
63+
64+
it('should not call setAssignedTheme for an unknown key', async () => {
65+
const mockProvider = { setAssignedTheme: vi.fn() } as unknown as ThemeProviderService
66+
67+
await applyTheme('nonexistent-theme', mockProvider)
68+
69+
expect(mockProvider.setAssignedTheme).not.toHaveBeenCalled()
70+
})
71+
72+
it('should load the light theme correctly', async () => {
73+
const mockProvider = { setAssignedTheme: vi.fn() } as unknown as ThemeProviderService
74+
75+
await applyTheme('light', mockProvider)
76+
77+
expect(mockProvider.setAssignedTheme).toHaveBeenCalledOnce()
78+
})
79+
})
80+
})

0 commit comments

Comments
 (0)