import { beforeEach, describe, expect, it, vi } from 'vitest' import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime' import { flushPromises } from '@vue/test-utils' import { nextTick, ref } from 'vue' import EditAsset from '../../app/pages/assets/[id]/edit.vue' import NewAsset from '../../app/pages/assets/new.vue' import AssetCategoryField from '../../app/components/AssetCategoryField.vue' const { api, mutate, toast, clearAsset, featureFlags, featureRef } = vi.hoisted(() => { const featureFlags = { locations: true, collections: true, tags: true, categories: true, attributes: true, conditions: true, warranties: true, plugins: true } return { api: vi.fn(), mutate: vi.fn(), toast: vi.fn(), clearAsset: vi.fn(), featureFlags, featureRef: { __v_isRef: true, value: featureFlags } } }) mockNuxtImport('useApi', () => api) mockNuxtImport('useApiFetch', () => () => mutate) mockNuxtImport('useToast', () => () => ({ add: toast })) mockNuxtImport('useRoute', () => () => ({ params: { id: 'asset' }, query: {} })) mockNuxtImport('useFeatures', () => () => ({ features: featureRef })) describe('Asset form sections', () => { const asset = ref | null>(null) beforeEach(() => { vi.clearAllMocks() Object.assign(featureFlags, { locations: true, collections: true, tags: true, categories: true, attributes: true, conditions: true, warranties: true, plugins: true }) asset.value = { id: 'asset', name: 'Desk', quantity: 1 } api.mockImplementation((url: unknown) => ({ data: typeof url === 'function' ? asset : ref([]), status: ref('success'), error: ref(null), clear: clearAsset })) mutate.mockResolvedValue({ id: 'new-asset' }) }) it('always shows both sections when creating an asset', async () => { const wrapper = await mountSuspended(NewAsset) for (const title of ['Details and notes', 'Purchase information']) { const heading = wrapper.findAll('h2').find(node => node.text() === title)! expect(heading.element.closest('section')).not.toBeNull() expect(heading.element.closest('details')).toBeNull() } wrapper.unmount() }) describe.each([ ['create', NewAsset, 'Failed to create asset'], ['edit', EditAsset, 'Failed to update asset'] ] as const)('%s save errors', (mode, component, fallback) => { it.each([ [ { data: { error: 'required category attributes are missing: Serial number' }, message: '[PUT] /api/assets/asset: 400 Bad Request' }, 'required category attributes are missing: Serial number' ], [new Error('Network unavailable'), 'Network unavailable'], [{}, fallback] ])('shows the useful error message for %j', async (error, expected) => { mutate.mockImplementation(async (url: string) => { if (url.startsWith('/api/categories/')) { return { id: 'electronics', name: 'Electronics', attributes: [{ attribute_id: 'serial', required: true, attribute: { key: 'serial', name: 'Serial number', data_type: 'string' } }] } } throw error }) if (mode === 'edit') asset.value = { ...asset.value, category_id: 'electronics' } const wrapper = await mountSuspended(component) if (mode === 'create') { await wrapper.get('#asset-name').setValue('Desk') wrapper.getComponent(AssetCategoryField).vm.$emit('update:modelValue', 'electronics') } await flushPromises() await wrapper.get('form').trigger('submit') await flushPromises() expect(mutate).toHaveBeenCalledWith( mode === 'create' ? '/api/assets' : '/api/assets/asset', expect.objectContaining({ method: mode === 'create' ? 'POST' : 'PUT' }) ) expect(toast).toHaveBeenCalledExactlyOnceWith({ title: expected, color: 'error' }) wrapper.unmount() }) }) it.each([ [{}, false, false], [{ description: ' ', notes: '\n', purchase_note: ' ' }, false, false], [{ description: 'Oak desk' }, true, false], [{ notes: 'Upstairs' }, true, false], [{ condition_id: 'used' }, true, false], [{ quantity: 2 }, true, false], [{ purchase_at: '2026-09-06' }, false, true], [{ purchase_price: 0 }, false, true], [{ purchase_note: 'Gift' }, false, true] ])('sets edit expansion from saved fields %j', async (fields, details, purchase) => { const wrapper = await mountSuspended(EditAsset) asset.value = { ...asset.value, ...fields } await nextTick() const sections = wrapper.findAll('details') expect((sections[0]!.element as HTMLDetailsElement).open).toBe(details) expect((sections[1]!.element as HTMLDetailsElement).open).toBe(purchase) wrapper.unmount() }) it('does not collapse an expanded section when its text is cleared', async () => { asset.value = { ...asset.value, notes: 'A note' } const wrapper = await mountSuspended(EditAsset) const section = wrapper.findAll('details')[0]! await section.findAll('textarea')[1]!.setValue('') expect((section.element as HTMLDetailsElement).open).toBe(true) wrapper.unmount() }) it('creates an asset without a category', async () => { const wrapper = await mountSuspended(NewAsset) await wrapper.get('#asset-name').setValue('Unsorted item') await wrapper.get('#new-asset-form').trigger('submit') await flushPromises() const payload = JSON.parse(mutate.mock.calls[0]![1].body) expect(payload.name).toBe('Unsorted item') expect(payload).not.toHaveProperty('category_id') wrapper.unmount() }) it.each(['locations', 'collections', 'tags', 'categories', 'attributes', 'conditions'] as const)( 'omits disabled %s data from asset creation', async (feature) => { featureFlags[feature] = false const wrapper = await mountSuspended(NewAsset) await wrapper.get('#asset-name').setValue('Feature-safe asset') await wrapper.get('#new-asset-form').trigger('submit') await flushPromises() const payload = JSON.parse(mutate.mock.calls.find(([url]) => url === '/api/assets')![1].body) const property = { locations: 'location_id', collections: 'collection_ids', tags: 'tag_ids', categories: 'category_id', attributes: 'attributes', conditions: 'condition_id' }[feature] expect(payload).not.toHaveProperty(property) wrapper.unmount() } ) it('does not request or render disabled asset feature controls', async () => { Object.assign(featureFlags, { locations: false, collections: false, tags: false, categories: false, attributes: false, conditions: false }) const wrapper = await mountSuspended(NewAsset) expect(api).toHaveBeenCalledWith('/api/categories', { immediate: false }) expect(api).toHaveBeenCalledWith('/api/locations', { immediate: false }) expect(api).toHaveBeenCalledWith('/api/conditions', { immediate: false }) expect(api).toHaveBeenCalledWith('/api/tags', { immediate: false }) expect(wrapper.text()).not.toContain('Manage categories') expect(wrapper.text()).not.toContain('Collections (optional)') expect(wrapper.text()).not.toContain('Select condition') wrapper.unmount() }) it('omits preserved feature data when editing with features disabled', async () => { Object.assign(featureFlags, { locations: false, collections: false, tags: false, categories: false, attributes: false, conditions: false, plugins: false }) asset.value = { ...asset.value, category_id: 'books', location_id: 'office', collection_ids: ['favorites'], tag_ids: ['retro'], tags: [{ id: 'retro', name: 'Retro' }], condition_id: 'good', attributes: { 'serial': 'user-value', 'plugin.google_books.books.isbn': '123' } } const wrapper = await mountSuspended(EditAsset) await wrapper.get('form').trigger('submit') await flushPromises() const updateCall = mutate.mock.calls.find(([url]) => url === '/api/assets/asset')! const payload = JSON.parse(updateCall[1].body) for (const property of [ 'category_id', 'location_id', 'collection_ids', 'tag_ids', 'new_tag_names', 'condition_id', 'attributes' ]) { expect(payload).not.toHaveProperty(property) } wrapper.unmount() }) it('removes a category and its attributes while editing', async () => { asset.value = { ...asset.value, category_id: 'games', attributes: { platform: 'PS5' } } mutate.mockImplementation(async (url: string) => url === '/api/categories/games' ? { id: 'games', name: 'Games', attributes: [] } : undefined) const wrapper = await mountSuspended(EditAsset) await nextTick() wrapper.getComponent(AssetCategoryField).vm.$emit('update:modelValue', undefined) await nextTick() await wrapper.get('form').trigger('submit') await flushPromises() const updateCall = mutate.mock.calls.find(([url]) => url === '/api/assets/asset')! const payload = JSON.parse(updateCall[1].body) expect(payload).not.toHaveProperty('category_id') expect(payload).not.toHaveProperty('attributes') expect(clearAsset).toHaveBeenCalledOnce() wrapper.unmount() }) it('loads category fields when categorizing an uncategorized asset while editing', async () => { mutate.mockImplementation(async (url: string) => url === '/api/categories/games?inherited=true' ? { id: 'games', name: 'Games', attributes: [{ attribute_id: 'platform', attribute: { key: 'platform', name: 'Platform', data_type: 'string' } }] } : undefined) const wrapper = await mountSuspended(EditAsset) wrapper.getComponent(AssetCategoryField).vm.$emit('update:modelValue', 'games') await flushPromises() expect(mutate).toHaveBeenCalledWith('/api/categories/games?inherited=true') wrapper.unmount() }) })