fix architecture and write new tests
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installBrowser(token?: string) {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => token ?? null),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('dashboard apiFetch', () => {
|
||||
it('adds JSON headers and sends cookies for browser requests', async () => {
|
||||
installBrowser()
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { ok: true } }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { apiFetch } = await import('./api')
|
||||
await expect(apiFetch('/team')).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/dashboard/api/v1/team', expect.objectContaining({
|
||||
credentials: 'include',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not force JSON content type for FormData payloads', async () => {
|
||||
installBrowser()
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { apiFetch } = await import('./api')
|
||||
await apiFetch('/uploads', { method: 'POST', body: new FormData() })
|
||||
|
||||
expect((fetchMock as any).mock.calls[0]?.[1].headers).not.toHaveProperty('Content-Type')
|
||||
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: 'include' }))
|
||||
})
|
||||
|
||||
it('surfaces API error details on failed responses', async () => {
|
||||
installBrowser()
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ message: 'Forbidden', error: 'FORBIDDEN' }),
|
||||
})),
|
||||
})
|
||||
|
||||
const { apiFetch } = await import('./api')
|
||||
await expect(apiFetch('/team')).rejects.toMatchObject({ message: 'Forbidden', code: 'FORBIDDEN', statusCode: 403 })
|
||||
})
|
||||
|
||||
it('uses explicit server token and disables caching for server requests', async () => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { apiFetchServer } = await import('./api')
|
||||
await apiFetchServer('/fleet', 'server-token')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://localhost:4000/api/v1/fleet', expect.objectContaining({
|
||||
cache: 'no-store',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer server-token' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -3,20 +3,9 @@ export const API_BASE =
|
||||
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
|
||||
: (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1')
|
||||
|
||||
export const EMPLOYEE_TOKEN_KEY = 'employee_token'
|
||||
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||
|
||||
async function getAuthToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
return localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = await getAuthToken()
|
||||
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
@@ -27,10 +16,6 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveBrowserAppUrl } from './appUrls'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('dashboard resolveBrowserAppUrl', () => {
|
||||
it('returns the fallback untouched on the server', () => {
|
||||
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('http://localhost:3001/dashboard')
|
||||
})
|
||||
|
||||
it('keeps local browser fallbacks local and removes trailing slash', () => {
|
||||
installWindow('localhost')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard/')).toBe('http://localhost:3001/dashboard')
|
||||
})
|
||||
|
||||
it('rewrites fallback origin to the current production host', () => {
|
||||
installWindow('tenant.example.com', 'https:')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('https://tenant.example.com/dashboard')
|
||||
})
|
||||
|
||||
it('falls back safely when the configured value is not a URL', () => {
|
||||
installWindow('tenant.example.com')
|
||||
expect(resolveBrowserAppUrl('/dashboard/')).toBe('/dashboard/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toDashboardAppPath, toPublicDashboardPath } from './dashboardPaths'
|
||||
|
||||
describe('dashboard path normalization', () => {
|
||||
it('collapses empty and root values to the app root', () => {
|
||||
expect(toDashboardAppPath()).toBe('/')
|
||||
expect(toDashboardAppPath('')).toBe('/')
|
||||
expect(toDashboardAppPath('/')).toBe('/')
|
||||
})
|
||||
|
||||
it('strips repeated dashboard base prefixes', () => {
|
||||
expect(toDashboardAppPath('/dashboard')).toBe('/')
|
||||
expect(toDashboardAppPath('/dashboard/fleet')).toBe('/fleet')
|
||||
expect(toDashboardAppPath('/dashboard/dashboard/reservations')).toBe('/reservations')
|
||||
})
|
||||
|
||||
it('adds the public deployment prefix exactly once', () => {
|
||||
expect(toPublicDashboardPath('/')).toBe('/dashboard')
|
||||
expect(toPublicDashboardPath('/dashboard/fleet')).toBe('/dashboard/fleet')
|
||||
expect(toPublicDashboardPath('customers')).toBe('/dashboard/customers')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getScopedPreferenceCookieName,
|
||||
getScopedPreferenceKey,
|
||||
readCurrentUserScopedPreference,
|
||||
readScopedPreference,
|
||||
writeScopedPreference,
|
||||
} from './preferences'
|
||||
|
||||
function installBrowser(cookie = '') {
|
||||
const store = new Map<string, string>()
|
||||
let cookieValue = cookie
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get cookie() {
|
||||
return cookieValue
|
||||
},
|
||||
set cookie(value: string) {
|
||||
const [pair] = value.split(';')
|
||||
const [name, val] = pair.split('=')
|
||||
const existing = cookieValue
|
||||
.split(';')
|
||||
.map((chunk) => chunk.trim())
|
||||
.filter(Boolean)
|
||||
.filter((chunk) => !chunk.startsWith(`${name}=`))
|
||||
existing.push(`${name}=${val}`)
|
||||
cookieValue = existing.join('; ')
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(globalThis, 'atob', {
|
||||
configurable: true,
|
||||
value: (value: string) => Buffer.from(value, 'base64').toString('binary'),
|
||||
})
|
||||
return { store, get cookie() { return cookieValue } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installBrowser()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
Reflect.deleteProperty(globalThis, 'atob')
|
||||
})
|
||||
|
||||
describe('dashboard scoped preferences', () => {
|
||||
it('uses base keys when no employee token is available', () => {
|
||||
expect(getScopedPreferenceKey('theme')).toBe('theme')
|
||||
expect(getScopedPreferenceCookieName('theme')).toBe('theme')
|
||||
})
|
||||
|
||||
it('prefers scoped cookie/current user values before local storage', () => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
const browser = installBrowser('theme=dark')
|
||||
browser.store.set('theme', 'local-dark')
|
||||
|
||||
expect(readCurrentUserScopedPreference('theme')).toBe('dark')
|
||||
expect(readScopedPreference('theme')).toBe('dark')
|
||||
})
|
||||
|
||||
it('falls back through shared cookie, scoped local storage, base key, and legacy keys', () => {
|
||||
const browser = installBrowser('theme=dark')
|
||||
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('dark')
|
||||
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
const second = installBrowser()
|
||||
second.store.set('theme', 'base')
|
||||
second.store.set('legacy-theme', 'legacy')
|
||||
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('base')
|
||||
})
|
||||
|
||||
it('writes shared and legacy preference values without auth-token scoping', () => {
|
||||
const browser = installBrowser()
|
||||
|
||||
writeScopedPreference('language', 'fr', ['old-language'])
|
||||
|
||||
expect(browser.store.get('language')).toBe('fr')
|
||||
expect(browser.store.get('old-language')).toBe('fr')
|
||||
expect(browser.cookie).toContain('language=fr')
|
||||
})
|
||||
})
|
||||
@@ -3,16 +3,7 @@ export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
|
||||
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
||||
|
||||
function readEmployeeToken() {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const localToken = window.localStorage.getItem('employee_token')
|
||||
if (localToken) return localToken
|
||||
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.map((chunk) => chunk.trim())
|
||||
.find((chunk) => chunk.startsWith('employee_token='))
|
||||
?.split('=')[1] ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
function decodeEmployeeId(token: string | null) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
async function loadUrls(env: Record<string, string | undefined> = {}) {
|
||||
vi.resetModules()
|
||||
for (const key of ['NEXT_PUBLIC_MARKETPLACE_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
|
||||
if (env[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = env[key]
|
||||
}
|
||||
}
|
||||
return import('./urls')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
|
||||
delete process.env.NEXT_PUBLIC_ADMIN_URL
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('dashboard cross-app URLs', () => {
|
||||
it('uses default marketplace and admin bases on the server', async () => {
|
||||
const { marketplaceUrl, adminUrl } = await loadUrls()
|
||||
|
||||
expect(marketplaceUrl).toBe('http://localhost:3000')
|
||||
expect(adminUrl).toBe('http://localhost:3000/admin')
|
||||
})
|
||||
|
||||
it('strips trailing slashes from configured absolute URLs', async () => {
|
||||
const { marketplaceUrl, adminUrl } = await loadUrls({
|
||||
NEXT_PUBLIC_MARKETPLACE_URL: 'https://market.example.com/',
|
||||
NEXT_PUBLIC_ADMIN_URL: 'https://ops.example.com/admin/',
|
||||
})
|
||||
|
||||
expect(marketplaceUrl).toBe('https://market.example.com')
|
||||
expect(adminUrl).toBe('https://ops.example.com/admin')
|
||||
})
|
||||
|
||||
it('rewrites local browser fallbacks onto the current production host', async () => {
|
||||
installWindow('tenant.rentaldrivego.com', 'https:')
|
||||
const { marketplaceUrl, adminUrl } = await loadUrls()
|
||||
|
||||
expect(marketplaceUrl).toBe('https://tenant.rentaldrivego.com')
|
||||
expect(adminUrl).toBe('https://tenant.rentaldrivego.com/admin')
|
||||
})
|
||||
|
||||
it('preserves non-URL values after normalizing their trailing slash', async () => {
|
||||
const { marketplaceUrl, adminUrl } = await loadUrls({
|
||||
NEXT_PUBLIC_MARKETPLACE_URL: '/marketplace/',
|
||||
NEXT_PUBLIC_ADMIN_URL: '/admin/',
|
||||
})
|
||||
|
||||
expect(marketplaceUrl).toBe('/marketplace')
|
||||
expect(adminUrl).toBe('/admin')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user