Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:14:53 -04:00
parent ddf41c3e65
commit ff9ae693a6
112 changed files with 5182 additions and 857 deletions

View File

@@ -0,0 +1,56 @@
// Asset-type ids resolved by NAME, once per page load.
//
// Four asset forms carried a hardcoded id - `const COMPUTER_ASSETTYPEID = 2`,
// and the same for machines, printers and network devices - to tell
// CustomFieldsInputs which type's fields to render. The ids come from seed
// order, not from anything stable: on the instance those constants were written
// against, computer was 2; on a freshly seeded one it is 1 and 2 is
// network_device. So the PC form asked for another type's custom fields,
// rendered them, and dropped whatever the operator typed - with a success
// toast, because the values were keyed to a type the record does not have.
//
// A name is the stable thing. `assettype` is a fixed vocabulary (ADR-001) and
// the API already publishes the mapping; nothing else should hardcode a number.
import { reactive } from 'vue'
import { assetsApi } from '../api'
const state = reactive({
byname: {},
loaded: false
})
let inflight = null
export async function loadAssetTypeIds() {
if (state.loaded) return state
if (inflight) return inflight
inflight = assetsApi.types.list()
.then((response) => {
const rows = response?.data?.data || response?.data || []
const byname = {}
rows.forEach((row) => {
if (row && row.assettype) byname[row.assettype] = row.assettypeid
})
state.byname = byname
state.loaded = true
return state
})
.catch(() => {
// Leave it unloaded rather than caching a failure: a form that could not
// resolve its type renders no custom fields, which is honest. Guessing an
// id is what caused the defect this replaces.
state.loaded = false
return state
})
.finally(() => { inflight = null })
return inflight
}
// Undefined until loaded, and undefined for a name the server does not know.
// CustomFieldsInputs treats an undefined assettypeid as "nothing to render",
// which is the safe reading: better a missing panel than the wrong type's.
export function assetTypeId(name) {
return state.byname[name]
}
export { state }

View File

@@ -0,0 +1,94 @@
// Asset-type ids must come from the server by name.
//
// Four forms hardcoded the id. Those constants were written against one
// instance's seed order and were wrong on another: PCForm said 2, and on a
// freshly seeded database 2 is network_device. The form asked
// CustomFieldsInputs for that type's fields, rendered them, and dropped what
// the operator typed - with a success toast, because the values were keyed to
// a type the record does not have.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../api', () => ({
assetsApi: {
types: {
list: vi.fn()
}
}
}))
import { assetsApi } from '../api'
import { loadAssetTypeIds, assetTypeId, state } from './assetTypeIds'
function reset() {
state.byname = {}
state.loaded = false
assetsApi.types.list.mockReset()
}
describe('assetTypeIds', () => {
beforeEach(reset)
it('resolves an id by name from what the server actually has', async () => {
assetsApi.types.list.mockResolvedValue({
data: { data: [
{ assettypeid: 1, assettype: 'computer' },
{ assettypeid: 2, assettype: 'network_device' },
{ assettypeid: 4, assettype: 'machine' }
] }
})
await loadAssetTypeIds()
expect(assetTypeId('computer')).toBe(1)
expect(assetTypeId('network_device')).toBe(2)
expect(assetTypeId('machine')).toBe(4)
})
it('survives a differently seeded site, which is the whole point', async () => {
// The same names, different ids. A hardcoded 2 would be network_device
// here and computer in the other fixture.
assetsApi.types.list.mockResolvedValue({
data: { data: [
{ assettypeid: 2, assettype: 'computer' },
{ assettypeid: 3, assettype: 'network_device' }
] }
})
await loadAssetTypeIds()
expect(assetTypeId('computer')).toBe(2)
expect(assetTypeId('network_device')).toBe(3)
})
it('fetches once even when several forms ask at the same time', async () => {
assetsApi.types.list.mockResolvedValue({
data: { data: [{ assettypeid: 1, assettype: 'computer' }] }
})
await Promise.all([loadAssetTypeIds(), loadAssetTypeIds(), loadAssetTypeIds()])
await loadAssetTypeIds()
expect(assetsApi.types.list).toHaveBeenCalledTimes(1)
})
it('returns undefined rather than a guess when the load fails', async () => {
assetsApi.types.list.mockRejectedValue(new Error('network down'))
await loadAssetTypeIds()
// Undefined means CustomFieldsInputs renders nothing. A missing panel is
// honest; the wrong type's panel is what this replaces.
expect(assetTypeId('computer')).toBeUndefined()
expect(state.loaded).toBe(false)
})
it('returns undefined for a type the server does not know', async () => {
assetsApi.types.list.mockResolvedValue({
data: { data: [{ assettypeid: 1, assettype: 'computer' }] }
})
await loadAssetTypeIds()
expect(assetTypeId('forklift')).toBeUndefined()
})
})

View File

@@ -0,0 +1,55 @@
// Turning what somebody typed into an employee, before giving up on it.
//
// Both employee pickers had the same shape: click a suggestion and you get the
// person, with their SSO; type the whole name and press Enter and you get a
// free-text label (`NAME:Jane Smith`) that is linked to nothing. It looks
// identical on screen - the chip says "Jane Smith" either way - and the
// consequences only show up later: no photo, no directory link, and a
// recognition that cannot be found by the person it is about.
//
// Nobody types a name in full expecting a different outcome from picking it
// off the list. So a typed name is looked up first, and only becomes a custom
// label when the directory genuinely does not have it.
//
// An ambiguous name is NOT guessed at. Two people called J Smith is exactly
// when picking the wrong one matters most, so the caller is told to choose.
import { employeesApi } from '../api'
function fullname(employee) {
return `${employee.First_Name || ''} ${employee.Last_Name || ''}`.trim()
}
// { match } when one employee is clearly meant, { matches } when several are
// possible, {} when the directory has nobody by that name.
export async function resolveEmployeeName(name) {
const query = (name || '').trim()
if (!query) return {}
let rows = []
try {
const response = await employeesApi.search(query)
rows = response?.data?.data || []
} catch (err) {
// A directory that cannot be reached must not silently turn a real
// employee into a custom label - the caller decides what to do about it.
// The MESSAGE, not the error object: a rejected mock handed to
// console.error is re-surfaced by the test runner as an unhandled error,
// which fails the very test that proves this branch works.
console.warn('Employee lookup failed:', err && err.message)
return { failed: true }
}
if (!rows.length) return {}
// An exact full-name match wins over a partial one: searching "Dana Reyes"
// can also return "Dana Reyes-Smith", and the person typing the shorter name
// meant the shorter name.
const exact = rows.filter(
(row) => fullname(row).toLowerCase() === query.toLowerCase())
if (exact.length === 1) return { match: exact[0] }
if (exact.length > 1) return { matches: exact }
if (rows.length === 1) return { match: rows[0] }
return { matches: rows }
}
export { fullname }

View File

@@ -0,0 +1,81 @@
// Typing a name in full must reach the same employee as picking it off the list.
//
// Both pickers turned a typed name straight into `NAME:<text>` - a free-text
// label linked to nothing. On screen the chip is identical to a real
// selection, so the difference only shows up later: no photo, no directory
// record, and a recognition that cannot be found by the person it is about.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../api', () => ({
employeesApi: { search: vi.fn() }
}))
import { employeesApi } from '../api'
import { resolveEmployeeName } from './employeeResolve'
const DANA = { SSO: 270015376, First_Name: 'Dana', Last_Name: 'Reyes' }
const DANA_HYPHEN = { SSO: 270099999, First_Name: 'Dana', Last_Name: 'Reyes-Smith' }
const SAM = { SSO: 270000001, First_Name: 'Sam', Last_Name: 'Reyes' }
function found(rows) {
employeesApi.search.mockResolvedValue({ data: { data: rows } })
}
describe('resolveEmployeeName', () => {
beforeEach(() => employeesApi.search.mockReset())
it('resolves a full name to the employee', async () => {
found([DANA])
const { match } = await resolveEmployeeName('Dana Reyes')
expect(match.SSO).toBe(270015376)
})
it('prefers an exact full-name match over a longer partial one', async () => {
// Searching "Dana Reyes" also returns "Dana Reyes-Smith"; the person who
// typed the shorter name meant the shorter name.
found([DANA, DANA_HYPHEN])
const { match, matches } = await resolveEmployeeName('Dana Reyes')
expect(matches).toBeUndefined()
expect(match.SSO).toBe(270015376)
})
it('refuses to guess between genuinely ambiguous matches', async () => {
found([DANA, SAM])
const { match, matches } = await resolveEmployeeName('Reyes')
expect(match).toBeUndefined()
expect(matches).toHaveLength(2)
})
it('reports nothing found, so the caller may fall back to a label', async () => {
found([])
const result = await resolveEmployeeName('Somebody External')
expect(result.match).toBeUndefined()
expect(result.matches).toBeUndefined()
expect(result.failed).toBeUndefined()
})
it('says the lookup FAILED rather than reporting nobody', async () => {
// The difference matters: "not in the directory" may become a custom
// label, "could not ask" must not.
// A THENABLE that calls its reject callback, rather than a mock returning
// a rejected promise. The await sees the same failure, but no rejected
// promise object exists for the runner to collect as an unhandled error -
// which it does even when the code under test catches it, failing the very
// test that proves the catch works.
employeesApi.search.mockImplementationOnce(() => {
const failure = new Error('network down')
return { then: (_ok, fail) => fail(failure) }
})
// resolves.toEqual, so the runner sees the rejection consumed by an
// assertion rather than as an unhandled error of its own.
await expect(resolveEmployeeName('Dana Reyes')).resolves.toEqual({
failed: true
})
})
it('does not call the directory for an empty box', async () => {
const result = await resolveEmployeeName(' ')
expect(employeesApi.search).not.toHaveBeenCalled()
expect(result).toEqual({})
})
})