feat(parts): couple state and location (host vs bin)
DEPLOYED parts live on a host; every other state lives in a bin (or unassigned). Previously binId and hostId were independent nullable fields with no validation, so the Edit Part dialog could leave a DEPLOYED part with only a bin and no host — which silently dropped it from the repair problem-part picker. - Service: resolveLocation() helper enforces the invariant on create and update. On a state transition, update auto-clears the stale relation and emits LOCATION_CHANGED for the cleared side. - Zod: CreatePartRequest.superRefine rejects mismatched state/location up front; UpdatePartRequest rejects both-fields-set. - Web: PartFormDialog swaps a single Location field between Host combobox (DEPLOYED) and Bin combobox (others); switching State clears the opposite field. Parts list + detail render host first, then bin path, then Unassigned. - Tests: 9 new cases covering the invariant including the no-op guard so an unrelated PATCH on a DEPLOYED part doesn't touch hostId/binId. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Tx, Actor } from './types.js';
|
||||
import { create, update } from './parts.js';
|
||||
|
||||
const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' };
|
||||
|
||||
const partModel = {
|
||||
id: 'pm-1',
|
||||
manufacturerId: 'mfr-1',
|
||||
mpn: 'WD-1000',
|
||||
eolDate: null,
|
||||
notes: null,
|
||||
};
|
||||
|
||||
// Current-row fixtures used by update tests. Only the fields the service reads are populated.
|
||||
function sparePart(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'p-1',
|
||||
serialNumber: 'SN-1',
|
||||
partModelId: 'pm-1',
|
||||
manufacturerId: 'mfr-1',
|
||||
state: 'SPARE',
|
||||
binId: 'bin-1',
|
||||
hostId: null,
|
||||
categoryId: null,
|
||||
price: null,
|
||||
notes: null,
|
||||
partModel: { ...partModel },
|
||||
manufacturer: { id: 'mfr-1', name: 'WD' },
|
||||
bin: null,
|
||||
host: null,
|
||||
category: null,
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deployedPart(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return sparePart({
|
||||
state: 'DEPLOYED',
|
||||
binId: null,
|
||||
hostId: 'host-1',
|
||||
host: { id: 'host-1', name: 'rack-1', assetId: 'ASSET-001' },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('parts.create — state/location coupling', () => {
|
||||
it('rejects DEPLOYED without a hostId', async () => {
|
||||
const partCreate = vi.fn();
|
||||
const tx = {
|
||||
partModel: { findUnique: async () => partModel },
|
||||
part: { create: partCreate },
|
||||
partEvent: { create: vi.fn() },
|
||||
} as unknown as Tx;
|
||||
|
||||
await expect(
|
||||
create(
|
||||
tx,
|
||||
{ serialNumber: 'SN-1', partModelId: 'pm-1', state: 'DEPLOYED' },
|
||||
actor,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(partCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects DEPLOYED with both hostId and binId', async () => {
|
||||
const partCreate = vi.fn();
|
||||
const tx = {
|
||||
partModel: { findUnique: async () => partModel },
|
||||
part: { create: partCreate },
|
||||
partEvent: { create: vi.fn() },
|
||||
} as unknown as Tx;
|
||||
|
||||
await expect(
|
||||
create(
|
||||
tx,
|
||||
{
|
||||
serialNumber: 'SN-1',
|
||||
partModelId: 'pm-1',
|
||||
state: 'DEPLOYED',
|
||||
hostId: 'host-1',
|
||||
binId: 'bin-1',
|
||||
},
|
||||
actor,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(partCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-DEPLOYED part that carries a hostId', async () => {
|
||||
const partCreate = vi.fn();
|
||||
const tx = {
|
||||
partModel: { findUnique: async () => partModel },
|
||||
part: { create: partCreate },
|
||||
partEvent: { create: vi.fn() },
|
||||
} as unknown as Tx;
|
||||
|
||||
await expect(
|
||||
create(
|
||||
tx,
|
||||
{ serialNumber: 'SN-1', partModelId: 'pm-1', state: 'SPARE', hostId: 'host-1' },
|
||||
actor,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(partCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a DEPLOYED part with hostId and writes binId=null', async () => {
|
||||
const created = sparePart({
|
||||
id: 'p-new',
|
||||
state: 'DEPLOYED',
|
||||
binId: null,
|
||||
hostId: 'host-1',
|
||||
host: { id: 'host-1', name: 'rack-1', assetId: 'ASSET-001' },
|
||||
});
|
||||
const partCreate = vi.fn();
|
||||
partCreate.mockResolvedValue(created);
|
||||
const partEventCreate = vi.fn();
|
||||
const tx = {
|
||||
partModel: { findUnique: async () => partModel },
|
||||
part: {
|
||||
create: partCreate,
|
||||
findUnique: async () => created,
|
||||
},
|
||||
partEvent: { create: partEventCreate },
|
||||
} as unknown as Tx;
|
||||
|
||||
const r = await create(
|
||||
tx,
|
||||
{
|
||||
serialNumber: 'SN-1',
|
||||
partModelId: 'pm-1',
|
||||
state: 'DEPLOYED',
|
||||
hostId: 'host-1',
|
||||
},
|
||||
actor,
|
||||
);
|
||||
expect(r.id).toBe('p-new');
|
||||
const callArgs = partCreate.mock.calls[0]![0] as { data: { binId: string | null; hostId: string | null } };
|
||||
expect(callArgs.data.binId).toBeNull();
|
||||
expect(callArgs.data.hostId).toBe('host-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parts.update — state/location coupling', () => {
|
||||
it('promoting SPARE→DEPLOYED with a hostId clears binId', async () => {
|
||||
const current = sparePart({ binId: 'bin-1', hostId: null });
|
||||
const partUpdate = vi.fn();
|
||||
partUpdate.mockResolvedValue(sparePart({ state: 'DEPLOYED', binId: null, hostId: 'host-1' }));
|
||||
const tx = {
|
||||
part: {
|
||||
findUnique: async () => current,
|
||||
update: partUpdate,
|
||||
},
|
||||
partEvent: { createMany: vi.fn() },
|
||||
partTag: { findMany: async () => [] },
|
||||
} as unknown as Tx;
|
||||
|
||||
await update(tx, 'p-1', { state: 'DEPLOYED', hostId: 'host-1' }, actor);
|
||||
|
||||
const call = partUpdate.mock.calls[0]![0] as { data: { bin?: unknown; host?: unknown } };
|
||||
expect(call.data.bin).toEqual({ disconnect: true });
|
||||
expect(call.data.host).toEqual({ connect: { id: 'host-1' } });
|
||||
});
|
||||
|
||||
it('demoting DEPLOYED→BROKEN with a binId clears hostId', async () => {
|
||||
const current = deployedPart();
|
||||
const partUpdate = vi.fn();
|
||||
partUpdate.mockResolvedValue(sparePart({ state: 'BROKEN', binId: 'bin-2', hostId: null }));
|
||||
const tx = {
|
||||
part: {
|
||||
findUnique: async () => current,
|
||||
update: partUpdate,
|
||||
},
|
||||
partEvent: { createMany: vi.fn() },
|
||||
partTag: { findMany: async () => [] },
|
||||
} as unknown as Tx;
|
||||
|
||||
await update(tx, 'p-1', { state: 'BROKEN', binId: 'bin-2' }, actor);
|
||||
|
||||
const call = partUpdate.mock.calls[0]![0] as { data: { bin?: unknown; host?: unknown } };
|
||||
expect(call.data.bin).toEqual({ connect: { id: 'bin-2' } });
|
||||
expect(call.data.host).toEqual({ disconnect: true });
|
||||
});
|
||||
|
||||
it('rejects a DEPLOYED transition when neither current nor input supplies a hostId', async () => {
|
||||
const current = sparePart({ binId: 'bin-1', hostId: null });
|
||||
const partUpdate = vi.fn();
|
||||
const tx = {
|
||||
part: {
|
||||
findUnique: async () => current,
|
||||
update: partUpdate,
|
||||
},
|
||||
partEvent: { createMany: vi.fn() },
|
||||
} as unknown as Tx;
|
||||
|
||||
await expect(
|
||||
update(tx, 'p-1', { state: 'DEPLOYED' }, actor),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(partUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects DEPLOYED→DEPLOYED with a binId (bin not allowed on DEPLOYED)', async () => {
|
||||
const current = deployedPart();
|
||||
const partUpdate = vi.fn();
|
||||
const tx = {
|
||||
part: {
|
||||
findUnique: async () => current,
|
||||
update: partUpdate,
|
||||
},
|
||||
partEvent: { createMany: vi.fn() },
|
||||
} as unknown as Tx;
|
||||
|
||||
await expect(
|
||||
update(tx, 'p-1', { binId: 'bin-2' }, actor),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(partUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no-op update (notes only) on a DEPLOYED part does not touch bin or host', async () => {
|
||||
const current = deployedPart();
|
||||
const partUpdate = vi.fn();
|
||||
partUpdate.mockResolvedValue(deployedPart({ notes: 'ok' }));
|
||||
const tx = {
|
||||
part: {
|
||||
findUnique: async () => current,
|
||||
update: partUpdate,
|
||||
},
|
||||
partEvent: { createMany: vi.fn() },
|
||||
partTag: { findMany: async () => [] },
|
||||
} as unknown as Tx;
|
||||
|
||||
await update(tx, 'p-1', { notes: 'ok' }, actor);
|
||||
|
||||
const call = partUpdate.mock.calls[0]![0] as { data: { bin?: unknown; host?: unknown } };
|
||||
expect(call.data.bin).toBeUndefined();
|
||||
expect(call.data.host).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreatePartRequest,
|
||||
PaginationQuery,
|
||||
PartListQuery,
|
||||
PartState as PartStateValue,
|
||||
UpdatePartRequest,
|
||||
} from '@vector/shared';
|
||||
import { errors } from '../lib/http-error.js';
|
||||
@@ -10,6 +11,29 @@ import * as partModelsSvc from './part-models.js';
|
||||
import * as tagsSvc from './tags.js';
|
||||
import type { Actor, Tx } from './types.js';
|
||||
|
||||
// DEPLOYED parts live on a host; every other state lives in a bin (or is unassigned).
|
||||
// This helper enforces the invariant on create/update and auto-clears the stale field on a
|
||||
// state transition, so callers don't have to remember to null the opposite relation.
|
||||
function resolveLocation(
|
||||
state: PartStateValue,
|
||||
input: { binId?: string | null; hostId?: string | null },
|
||||
current: { binId: string | null; hostId: string | null } = { binId: null, hostId: null },
|
||||
): { binId: string | null; hostId: string | null } {
|
||||
if (state === 'DEPLOYED') {
|
||||
const hostId = input.hostId !== undefined ? input.hostId : current.hostId;
|
||||
if (!hostId) throw errors.badRequest('A deployed part must be assigned to a host');
|
||||
if (input.binId) {
|
||||
throw errors.badRequest('A deployed part cannot also be in a storage bin');
|
||||
}
|
||||
return { binId: null, hostId };
|
||||
}
|
||||
if (input.hostId) {
|
||||
throw errors.badRequest('Only deployed parts can be assigned to a host');
|
||||
}
|
||||
const binId = input.binId !== undefined ? input.binId : current.binId;
|
||||
return { binId, hostId: null };
|
||||
}
|
||||
|
||||
const partInclude = {
|
||||
manufacturer: true,
|
||||
partModel: true,
|
||||
@@ -123,6 +147,9 @@ export async function create(
|
||||
throw errors.badRequest('manufacturerId does not match the selected part model');
|
||||
}
|
||||
|
||||
const state = input.state ?? 'SPARE';
|
||||
const location = resolveLocation(state, { binId: input.binId, hostId: input.hostId });
|
||||
|
||||
try {
|
||||
const p = await tx.part.create({
|
||||
data: {
|
||||
@@ -130,9 +157,9 @@ export async function create(
|
||||
partModelId,
|
||||
manufacturerId,
|
||||
price: input.price ?? null,
|
||||
state: input.state ?? 'SPARE',
|
||||
binId: input.binId ?? null,
|
||||
hostId: input.hostId ?? null,
|
||||
state,
|
||||
binId: location.binId,
|
||||
hostId: location.hostId,
|
||||
categoryId: input.categoryId ?? null,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
@@ -179,12 +206,24 @@ export async function update(
|
||||
}
|
||||
if (input.price !== undefined) data.price = input.price;
|
||||
if (input.state !== undefined) data.state = input.state;
|
||||
if (input.binId !== undefined) {
|
||||
data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true };
|
||||
}
|
||||
if (input.hostId !== undefined) {
|
||||
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true };
|
||||
|
||||
let nextBinId: string | null = current.binId;
|
||||
let nextHostId: string | null = current.hostId;
|
||||
const locationTouched =
|
||||
input.state !== undefined || input.binId !== undefined || input.hostId !== undefined;
|
||||
if (locationTouched) {
|
||||
const nextState = input.state ?? (current.state as PartStateValue);
|
||||
const resolved = resolveLocation(
|
||||
nextState,
|
||||
{ binId: input.binId, hostId: input.hostId },
|
||||
{ binId: current.binId, hostId: current.hostId },
|
||||
);
|
||||
nextBinId = resolved.binId;
|
||||
nextHostId = resolved.hostId;
|
||||
data.bin = resolved.binId ? { connect: { id: resolved.binId } } : { disconnect: true };
|
||||
data.host = resolved.hostId ? { connect: { id: resolved.hostId } } : { disconnect: true };
|
||||
}
|
||||
|
||||
if (input.categoryId !== undefined) {
|
||||
data.category = input.categoryId
|
||||
? { connect: { id: input.categoryId } }
|
||||
@@ -216,7 +255,7 @@ export async function update(
|
||||
newValue: input.state,
|
||||
});
|
||||
}
|
||||
if (input.binId !== undefined && input.binId !== current.binId) {
|
||||
if (nextBinId !== current.binId) {
|
||||
events.push({
|
||||
partId: part.id,
|
||||
userId,
|
||||
@@ -226,7 +265,7 @@ export async function update(
|
||||
newValue: binPath(part.bin),
|
||||
});
|
||||
}
|
||||
if (input.hostId !== undefined && input.hostId !== current.hostId) {
|
||||
if (nextHostId !== current.hostId) {
|
||||
events.push({
|
||||
partId: part.id,
|
||||
userId,
|
||||
|
||||
Reference in New Issue
Block a user