Ctrl K
ComunidadGuías…

frontend/tests/pages/asset-containment.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string> },
  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<typeof globalThis.$fetch>) => 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<string, unknown> }[] = []
const listQueries: URLSearchParams[] = []
let assets: Record<string, Asset>
const wrappers: { unmount: () => void }[] = []

function fixture(id: string, name: string, fields: Partial<Asset> = {}): Asset {
  return { id, name, organization_id: 'org', quantity: 1, created_at: '', updated_at: '', ...fields }
}

async function mounted(component: Parameters<typeof mountSuspended>[0], options: Parameters<typeof mountSuspended>[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<string, unknown> })
      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<string, unknown> })
      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<string, unknown> })
      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<void>((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<string, unknown> })
        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))
  })
})