import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime' import { flushPromises } from '@vue/test-utils' import { clearNuxtData } from '#app' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { ref } from 'vue' import { createFetch } from 'ofetch' import type { Asset } from '../../app/types/api' import AssetPicker from '../../app/components/AssetPicker.vue' import AssetContents from '../../app/components/AssetContents.vue' import AssetContentsNode from '../../app/components/AssetContentsNode.vue' import AssetContentsBrowser from '../../app/components/AssetContentsBrowser.vue' import NewAsset from '../../app/pages/assets/new.vue' import EditAsset from '../../app/pages/assets/[id]/edit.vue' // Restore Fetch globals before MSW evaluates its Request/Response subclasses. const fetchGlobals = await vi.hoisted(async () => { const { fetch, Headers, Request, Response } = await import('undici') const original = { fetch: globalThis.fetch, Headers: globalThis.Headers, Request: globalThis.Request, Response: globalThis.Response } Object.assign(globalThis, { fetch, Headers, Request, Response }) return original }) const currencySettings = ref({ currency: 'USD' }) const { route, toast, flags } = vi.hoisted(() => ({ route: { params: { id: 'pc' }, query: {} as Record }, toast: vi.fn(), flags: { locations: true, categories: false, collections: false, tags: false, conditions: false, warranties: false, plugins: false, attributes: false } })) // Nuxt 4.6 imports its fetch client from #build/fetch rather than the global. mockNuxtImport('$fetch', () => (...args: Parameters) => globalThis.$fetch(...args)) mockNuxtImport('useRuntimeConfig', () => () => ({ public: { apiBase: 'http://attic.test' }, app: { baseURL: '/', buildAssetsDir: '/_nuxt/', cdnURL: '' } })) mockNuxtImport('useFeatures', () => () => ({ features: ref(flags) })) mockNuxtImport('useRoute', () => () => route) mockNuxtImport('useToast', () => () => ({ add: toast })) mockNuxtImport('useOrganizationSettings', () => () => ({ settings: currencySettings })) const server = setupServer() const saves: { method: string, body: Record }[] = [] const listQueries: URLSearchParams[] = [] let assets: Record const wrappers: { unmount: () => void }[] = [] function fixture(id: string, name: string, fields: Partial = {}): Asset { return { id, name, organization_id: 'org', quantity: 1, created_at: '', updated_at: '', ...fields } } async function mounted(component: Parameters[0], options: Parameters[1] = {}) { const wrapper = await mountSuspended(component, options) wrappers.push(wrapper) await flushPromises() return wrapper } async function settle(assertion: () => void) { await vi.waitFor(assertion) } const originalFetch = globalThis.$fetch beforeAll(() => { server.listen({ onUnhandledRequest: 'bypass' }) globalThis.$fetch = createFetch({ fetch: globalThis.fetch }) }) afterAll(() => { server.close() Object.assign(globalThis, fetchGlobals) globalThis.$fetch = originalFetch }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) }) beforeEach(() => { vi.clearAllMocks() clearNuxtData() saves.length = 0 listQueries.length = 0 route.params.id = 'pc' route.query = {} currencySettings.value.currency = 'USD' flags.locations = true assets = { pc: fixture('pc', 'Gaming PC', { location_id: 'garage', location: { id: 'garage', name: 'Garage', organization_id: 'org', created_at: '', updated_at: '' } }), gpu: fixture('gpu', 'Old GPU', { parent_id: 'pc', parent: { id: 'pc', name: 'Gaming PC' }, purchase_price: 50 }), replacement: fixture('replacement', 'New GPU') } server.resetHandlers( http.get('http://attic.test/api/locations', () => HttpResponse.json([])), http.get('http://attic.test/api/assets', ({ request }) => { const params = new URL(request.url).searchParams listQueries.push(params) return HttpResponse.json({ assets: params.has('parent_id') ? [assets.gpu] : [assets.pc, assets.replacement], total: params.has('parent_id') ? 1 : 2, limit: 20, offset: 0 }) }), http.get('http://attic.test/api/assets/:id', ({ params }) => { const asset = assets[String(params.id)] return asset ? HttpResponse.json(asset) : HttpResponse.json({ error: 'asset not found' }, { status: 404 }) }), http.post('http://attic.test/api/assets', async ({ request }) => { saves.push({ method: 'POST', body: await request.json() as Record }) return HttpResponse.json({ id: 'created' }, { status: 201 }) }), http.put('http://attic.test/api/assets/:id', async ({ request }) => { saves.push({ method: 'PUT', body: await request.json() as Record }) return HttpResponse.json(assets.pc) }), http.patch('http://attic.test/api/assets/:id/parent', async ({ request }) => { saves.push({ method: 'PATCH', body: await request.json() as Record }) return HttpResponse.json(assets.pc) }) ) }) describe('Asset containment', () => { it('keeps the hierarchy available while browsing, with the total scoped to the root asset', async () => { currencySettings.value.currency = 'EUR' assets.gpu!.child_count = 0 const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc!, summary: { total_value: 12.5, unpriced_asset_count: 1 } } }) await settle(() => expect(wrapper.find('ul[aria-label="Asset results"]').exists()).toBe(true)) expect(wrapper.get('button[aria-label="Grid view"]').attributes('aria-pressed')).toBe('true') expect(wrapper.find('aside[aria-label="Asset hierarchy"]').exists()).toBe(true) expect(wrapper.find('button[aria-controls="contents-hierarchy"]').exists()).toBe(false) expect(wrapper.find('nav[aria-label="Contents path"]').exists()).toBe(false) const total = wrapper.get('[aria-label="Total cost including contents"]') expect(total.text()).toContain('Known total') expect(total.text()).toContain('€12.50') expect(total.text()).toContain('Gaming PC + all nested contents') expect(total.text()).toContain('1 price missing') await wrapper.get('button[aria-label="Browse Old GPU"]').trigger('click') await settle(() => expect(wrapper.text()).toContain('No contents yet')) expect(wrapper.get('nav[aria-label="Contents path"]').text()).toContain('Gaming PC') expect(wrapper.find('aside[aria-label="Asset hierarchy"]').exists()).toBe(true) expect(total.text()).toContain('Gaming PC + all nested contents') }) it('recovers the root count and total when retrying a failed contents request', async () => { let fail = true server.use(http.get('http://attic.test/api/assets', () => fail ? HttpResponse.json({ error: 'offline' }, { status: 400 }) : HttpResponse.json({ assets: [assets.gpu], total: 1 }))) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc!, summary: { total_value: 50, unpriced_asset_count: 0 } } }) await settle(() => expect(wrapper.text()).toContain('Contents could not be loaded')) expect(wrapper.text()).not.toContain('No contents yet') fail = false await wrapper.findAll('button').find(button => button.text() === 'Try again')!.trigger('click') await settle(() => expect(wrapper.find('ul[aria-label="Asset results"]').exists()).toBe(true)) expect(wrapper.get('[aria-label="Total cost including contents"]').text()).toContain('Total purchase cost') }) it('keeps empty assets compact with creation actions and no redundant total or search controls', async () => { server.use(http.get('http://attic.test/api/assets', () => HttpResponse.json({ assets: [], total: 0 }))) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc!, summary: { total_value: 0, unpriced_asset_count: 1 } } }) await settle(() => expect(wrapper.text()).toContain('No contents yet')) expect(wrapper.find('input[type="search"]').exists()).toBe(false) expect(wrapper.find('[aria-label="Total cost including contents"]').exists()).toBe(false) expect(wrapper.find('button[aria-controls="contents-hierarchy"]').exists()).toBe(false) expect(wrapper.find('a[href="/assets/new?parent_id=pc"]').exists()).toBe(true) expect(wrapper.findAll('button').some(button => button.text() === 'Link existing asset')).toBe(true) }) it('retains search and the cost summary when a populated container has no matching results', async () => { server.use(http.get('http://attic.test/api/assets', ({ request }) => HttpResponse.json( new URL(request.url).searchParams.has('q') ? { assets: [], total: 0 } : { assets: [assets.gpu], total: 1 } ))) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc!, summary: { total_value: 50, unpriced_asset_count: 0 } } }) await settle(() => expect(wrapper.find('ul[aria-label="Asset results"]').exists()).toBe(true)) await wrapper.get('input[type="search"]').setValue('missing') await settle(() => expect(wrapper.text()).toContain('No matching assets')) expect(wrapper.get('input[type="search"]').isVisible()).toBe(true) expect(wrapper.text()).not.toContain('No contents yet') expect(wrapper.get('[aria-label="Total cost including contents"]').text()).toContain('Total purchase cost') }) it('uses the organization currency and preserves fractional contents prices', async () => { currencySettings.value.currency = 'EUR' assets.gpu!.purchase_price = 0.25 assets.gpu!.quantity = 2 const row = await mounted(AssetContentsNode, { props: { asset: assets.gpu! } }) expect(row.text()).toContain('€0.50') const browser = await mounted(AssetContentsBrowser, { props: { asset: assets.pc! } }) await settle(() => expect(browser.get('ul[aria-label="Asset results"]').text()).toContain('€0.50')) expect(browser.text()).not.toContain('$') }) it('loads branches lazily and browses direct contents without eager grandchildren', async () => { assets.motherboard = fixture('motherboard', 'Motherboard', { parent_id: 'pc', child_count: 1 }) assets.ram = fixture('ram', '32 GB RAM', { parent_id: 'motherboard', child_count: 0 }) server.use(http.get('http://attic.test/api/assets', ({ request }) => { const params = new URL(request.url).searchParams listQueries.push(params) const children = params.get('parent_id') === 'pc' ? [assets.motherboard] : [assets.ram] return HttpResponse.json({ assets: children, total: children.length }) })) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc! } }) await settle(() => expect(wrapper.find('button[aria-label="Expand Motherboard"]').exists()).toBe(true)) expect(listQueries.map(query => query.get('parent_id'))).toEqual(['pc']) await wrapper.get('button[aria-label="Browse Motherboard"]').trigger('click') await settle(() => expect(wrapper.get('ul[aria-label="Asset results"]').text()).toContain('32 GB RAM')) expect(listQueries.map(query => query.get('parent_id'))).toEqual(['pc', 'motherboard']) expect(wrapper.get('nav[aria-label="Contents path"]').text()).toContain('Gaming PC') await wrapper.get('button[aria-label="Collapse Motherboard"]').trigger('click') await wrapper.get('button[aria-label="Expand Motherboard"]').trigger('click') expect(listQueries).toHaveLength(2) await wrapper.get('button[aria-label="Browse 32 GB RAM"]').trigger('click') await settle(() => expect(wrapper.text()).toContain('No contents yet')) expect(listQueries).toHaveLength(2) }) it('shows paginated descendants and their parent paths without loading their branches', async () => { server.use(http.get('http://attic.test/api/assets', ({ request }) => { const params = new URL(request.url).searchParams listQueries.push(params) const children = params.has('descendant_of') ? [fixture('ram', '32 GB RAM', { parent_id: 'motherboard', containment_path: [{ id: 'pc', name: 'Gaming PC' }, { id: 'motherboard', name: 'Motherboard' }] })] : [assets.gpu] return HttpResponse.json({ assets: children, total: params.has('descendant_of') ? 25 : 1 }) })) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc! } }) await settle(() => expect(wrapper.text()).toContain('Old GPU')) await wrapper.get('input[type="checkbox"]').setValue(true) await settle(() => expect(wrapper.text()).toContain('Inside Gaming PC › Motherboard')) expect(listQueries.map(query => query.get('parent_id') || query.get('descendant_of'))).toEqual(['pc', 'pc']) await wrapper.findAll('button').find(button => button.text() === 'Next contents')!.trigger('click') await settle(() => expect(listQueries.at(-1)?.get('offset')).toBe('24')) await wrapper.get('input[type="search"]').setValue('RAM') await settle(() => expect(listQueries.at(-1)?.get('q')).toBe('RAM')) expect(listQueries.at(-1)?.get('offset')).toBe('0') }) it('retains loaded tree siblings when switching containers and paginates results independently', async () => { const children = Array.from({ length: 25 }, (_, index) => fixture(`part-${index}`, `Part ${index}`, { parent_id: 'pc', child_count: 0 })) server.use(http.get('http://attic.test/api/assets', ({ request }) => { const params = new URL(request.url).searchParams listQueries.push(params) const start = Number(params.get('offset') || 0) return HttpResponse.json({ assets: children.slice(start, start + 24), total: children.length }) })) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc! } }) await settle(() => expect(wrapper.find('button[aria-label="Browse Part 0"]').exists()).toBe(true)) await wrapper.get('button[aria-label="Load more contents of Gaming PC"]').trigger('click') await settle(() => expect(wrapper.find('button[aria-label="Browse Part 24"]').exists()).toBe(true)) expect(wrapper.get('ul[aria-label="Asset results"]').findAll('li')).toHaveLength(24) await wrapper.get('button[aria-label="Browse Part 0"]').trigger('click') await wrapper.get('button[aria-label="Browse Gaming PC"]').trigger('click') await settle(() => expect(wrapper.get('ul[aria-label="Asset results"]').findAll('li')).toHaveLength(24)) expect(wrapper.find('button[aria-label="Browse Part 24"]').exists()).toBe(true) expect(listQueries).toHaveLength(2) }) it('retries failed branches without requesting unopened grandchildren', async () => { assets.motherboard = fixture('motherboard', 'Motherboard', { parent_id: 'pc', child_count: 1 }) let attempts = 0 server.use(http.get('http://attic.test/api/assets', ({ request }) => { const params = new URL(request.url).searchParams listQueries.push(params) if (params.get('parent_id') === 'motherboard') { attempts++ if (attempts === 1) return HttpResponse.json({ error: 'offline' }, { status: 400 }) return HttpResponse.json({ assets: [fixture('ram', 'RAM', { parent_id: 'motherboard', child_count: 0 })], total: 1 }) } return HttpResponse.json({ assets: [assets.motherboard], total: 1 }) })) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc! } }) await settle(() => expect(wrapper.find('button[aria-label="Expand Motherboard"]').exists()).toBe(true)) await wrapper.get('button[aria-label="Expand Motherboard"]').trigger('click') await settle(() => expect(wrapper.text()).toContain('Could not load Motherboard')) await wrapper.findAll('button').find(button => button.text() === 'Retry')!.trigger('click') await settle(() => expect(wrapper.find('button[aria-label="Browse RAM"]').exists()).toBe(true)) expect(attempts).toBe(2) expect(wrapper.get('ul[aria-label="Asset results"]').text()).toContain('Motherboard') expect(listQueries.some(query => query.get('parent_id') === 'ram')).toBe(false) }) it('keeps the latest selection when an earlier container response arrives late', async () => { assets.motherboard = fixture('motherboard', 'Motherboard', { parent_id: 'pc', child_count: 1 }) let release: (() => void) | undefined let returned = false server.use(http.get('http://attic.test/api/assets', async ({ request }) => { const params = new URL(request.url).searchParams if (params.get('parent_id') === 'motherboard') { await new Promise((resolve) => { release = resolve }) returned = true return HttpResponse.json({ assets: [fixture('ram', 'Late RAM', { parent_id: 'motherboard' })], total: 1 }) } return HttpResponse.json({ assets: [assets.motherboard], total: 1 }) })) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc! } }) await settle(() => expect(wrapper.find('button[aria-label="Browse Motherboard"]').exists()).toBe(true)) await wrapper.get('button[aria-label="Browse Motherboard"]').trigger('click') await settle(() => expect(release).toBeDefined()) await wrapper.get('button[aria-label="Browse Gaming PC"]').trigger('click') release!() await settle(() => expect(returned).toBe(true)) await flushPromises() expect(wrapper.get('ul[aria-label="Asset results"]').text()).toContain('Motherboard') expect(wrapper.get('ul[aria-label="Asset results"]').text()).not.toContain('Late RAM') expect(wrapper.get('button[aria-label="Browse Gaming PC"]').attributes('aria-pressed')).toBe('true') }) it('links into the selected nested container and refreshes its contents', async () => { assets.motherboard = fixture('motherboard', 'Motherboard', { parent_id: 'pc', child_count: 0 }) let linked = false server.use( http.get('http://attic.test/api/assets', ({ request }) => { const params = new URL(request.url).searchParams listQueries.push(params) const children = params.get('parent_id') === 'pc' ? [assets.motherboard] : linked ? [assets.replacement] : [] return HttpResponse.json({ assets: children, total: children.length }) }), http.patch('http://attic.test/api/assets/:id/parent', async ({ request }) => { saves.push({ method: 'PATCH', body: await request.json() as Record }) linked = true assets.motherboard!.child_count = 1 assets.replacement!.parent_id = 'motherboard' return HttpResponse.json(assets.replacement) }) ) const wrapper = await mounted(AssetContentsBrowser, { props: { asset: assets.pc! } }) await settle(() => expect(wrapper.find('button[aria-label="Browse Motherboard"]').exists()).toBe(true)) await wrapper.get('button[aria-label="Browse Motherboard"]').trigger('click') await wrapper.findAll('button').find(button => button.text() === 'Link existing asset')!.trigger('click') const picker = wrapper.getComponent(AssetPicker) picker.vm.$emit('update:modelValue', 'replacement') await settle(() => expect(wrapper.findAll('button').find(button => button.text() === 'Link asset')!.attributes('disabled')).toBeUndefined()) await wrapper.findAll('button').find(button => button.text() === 'Link asset')!.trigger('click') await settle(() => expect(wrapper.get('ul[aria-label="Asset results"]').text()).toContain('New GPU')) expect(saves).toEqual([{ method: 'PATCH', body: { parent_id: 'motherboard' } }]) expect(wrapper.emitted('changed')).toHaveLength(1) expect(wrapper.get('button[aria-label="Browse Motherboard"]').attributes('aria-pressed')).toBe('true') }) it('resolves a prefilled parent and submits it without an independent location', async () => { route.query = { parent_id: 'pc' } const wrapper = await mounted(NewAsset) await settle(() => expect(wrapper.text()).toContain('Location: Garage')) expect(wrapper.find('#location').exists()).toBe(false) await wrapper.get('#asset-name').setValue('New component') await wrapper.get('form').trigger('submit') await settle(() => expect(saves).toHaveLength(1)) expect(saves[0]?.body).toMatchObject({ parent_id: 'pc', name: 'New component' }) expect(saves[0]?.body).not.toHaveProperty('location_id') }) it('blocks creation with an unavailable prefilled parent and lets the user clear it', async () => { route.query = { parent_id: 'missing' } const wrapper = await mounted(NewAsset) await settle(() => expect(wrapper.text()).toContain('Selected asset is unavailable')) await wrapper.get('#asset-name').setValue('Component') await wrapper.get('form').trigger('submit') expect(saves).toHaveLength(0) await wrapper.findAll('button').find(button => button.text() === 'Clear inside')!.trigger('click') await wrapper.get('form').trigger('submit') await settle(() => expect(saves).toHaveLength(1)) expect(saves[0]?.body.parent_id).toBeNull() }) it('retains parent selection but hides its location when locations are disabled', async () => { route.query = { parent_id: 'pc' } flags.locations = false const wrapper = await mounted(NewAsset) await settle(() => expect(wrapper.text()).toContain('Gaming PC')) expect(wrapper.text()).not.toContain('Location: Garage') }) it('stages removal until the main edit form is saved', async () => { const wrapper = await mounted(EditAsset) await settle(() => expect(wrapper.find('button[aria-label="Detach Old GPU"]').exists()).toBe(true)) await wrapper.get('button[aria-label="Detach Old GPU"]').trigger('click') expect(wrapper.text()).toContain('Will detach on save') expect(saves).toHaveLength(0) await wrapper.get('form').trigger('submit') await settle(() => expect(saves).toHaveLength(1)) expect(saves[0]).toMatchObject({ method: 'PUT', body: { parent_id: null, remove_child_ids: ['gpu'], add_child_ids: [] } }) }) it('discards staged removals when cancelling the edit form', async () => { const wrapper = await mounted(EditAsset) await settle(() => expect(wrapper.find('button[aria-label="Detach Old GPU"]').exists()).toBe(true)) await wrapper.get('button[aria-label="Detach Old GPU"]').trigger('click') const cancel = wrapper.findAll('a').find(link => link.text() === 'Cancel') expect(cancel).toBeDefined() expect(cancel?.attributes('href')).toBe('/assets/pc') wrapper.unmount() expect(saves).toHaveLength(0) }) it('lets the user undo a staged removal without writing to the API', async () => { const wrapper = await mounted(AssetContents, { props: { assetId: 'pc', editing: true } }) await settle(() => expect(wrapper.find('button[aria-label="Detach Old GPU"]').exists()).toBe(true)) await wrapper.get('button[aria-label="Detach Old GPU"]').trigger('click') await wrapper.get('button[aria-label="Undo removal of Old GPU"]').trigger('click') expect(wrapper.text()).not.toContain('Will detach on save') expect(saves).toHaveLength(0) }) it('offers retry when contents loading fails', async () => { server.use(http.get('http://attic.test/api/assets', () => HttpResponse.json({ error: 'unavailable' }, { status: 400 }))) const wrapper = await mounted(AssetContents, { props: { assetId: 'pc' } }) await settle(() => expect(wrapper.text()).toContain('Contents could not be loaded')) server.use(http.get('http://attic.test/api/assets', () => HttpResponse.json({ assets: [assets.gpu], total: 1 }))) await wrapper.findAll('button').find(button => button.text() === 'Try again')!.trigger('click') await settle(() => expect(wrapper.text()).toContain('Old GPU')) }) it('queries valid parent candidates using server-side exclusions', async () => { const wrapper = await mounted(AssetPicker, { props: { label: 'Inside', excludeSubtreeOf: 'pc' } }) await wrapper.get('[aria-label="Inside"]').trigger('click') await settle(() => expect(listQueries.some(query => query.get('exclude_subtree_of') === 'pc')).toBe(true)) }) })