frontend/tests/pages/asset-sections.test.ts
Rama main · réplica de lectura del repositorio original.
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<Record<string, unknown> | 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()
})
})