Files
dashlibstorage/test/shipment.test.js
2026-10-17 19:03:24 +02:00

331 lines
15 KiB
JavaScript

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createTestStore, createTenant, createArticle, createCustomer } from './helpers.js';
describe('store.shipment', () => {
let ctx;
let store;
let tenant;
let other;
let gid;
let customer;
let seq = 0;
const makeOrder = async (lines = [
{ sku: 'SH-A', amount: 10, net: 100, taxrate: 19 },
{ sku: 'SH-B', amount: 5, net: 50, taxrate: 19 },
], date = '2026-10-12') => {
seq += 1;
return store.order.create(gid, {
number: `AU-${2000 + seq}`,
date,
entityId: customer.id,
lines,
});
};
before(async () => {
ctx = await createTestStore('dash-shipment-');
store = ctx.store;
tenant = await createTenant(store, { name: 'Shipment Test GmbH' });
other = await createTenant(store, { name: 'Shipment Other GmbH' });
gid = tenant.googleAuthId;
customer = await createCustomer(store, tenant, 'Max Mustermann');
// untracked articles -> no stock prerequisites for shipments
await createArticle(store, tenant, { name: 'Item A', sku: 'SH-A' });
await createArticle(store, tenant, { name: 'Item B', sku: 'SH-B' });
});
after(async () => {
await ctx.cleanup();
});
describe('create', () => {
it('creates numbered shipments from an order', async () => {
const order = await makeOrder();
const s1 = await store.shipment.create(gid, {
orderId: order.id,
lines: [{ sku: 'SH-A', amount: 6 }, { sku: 'SH-B', amount: 3 }],
});
const s2 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] });
assert.equal(s1.number, `${order.number}-L-1`);
assert.equal(s2.number, `${order.number}-L-2`);
assert.equal(s1.type, 'shipment');
assert.equal(s1.state, 'new');
assert.equal(s1.lineCount, 2);
assert.equal(s1.packageCount, 0);
assert.equal(s1.entityId, customer.id);
assert.deepEqual(s1.lines.map((l) => [l.sku, l.amount]), [['SH-A', 6], ['SH-B', 3]]);
// line data defaults from the order line
assert.equal(s1.lines[0].net, 100);
assert.equal(s1.lines[0].taxrate, 19);
});
it('defaults amount to the remaining quantity', async () => {
const order = await makeOrder();
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 3 }] });
const rest = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A' }] });
assert.equal(rest.lines[0].amount, 7);
});
it('ships everything remaining when lines are omitted', async () => {
const order = await makeOrder();
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] });
const rest = await store.shipment.create(gid, { orderId: order.id });
assert.deepEqual(
rest.lines.map((l) => [l.sku, l.amount]).sort(),
[['SH-A', 6], ['SH-B', 5]],
);
await assert.rejects(() => store.shipment.create(gid, { orderId: order.id }), { message: 'All items already shipped' });
});
it('updates the order summary', async () => {
const order = await makeOrder();
const summary = () => ctx.provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order.id);
assert.equal(summary().shipped_quantity, 0);
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] });
assert.equal(summary().shipped_quantity, 10);
assert.equal(summary().is_shipped, 0);
await store.shipment.create(gid, { orderId: order.id });
assert.equal(summary().shipped_quantity, 15);
assert.equal(summary().is_shipped, 1);
});
it('enforces the ordered quantity ceiling', async () => {
const order = await makeOrder();
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 6 }] });
await assert.rejects(
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 5 }] }),
{ message: 'Shipment amount exceeds ordered amount for article (SH-A)' },
);
await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] });
await assert.rejects(
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A' }] }),
{ message: 'Shipment amount exceeds ordered amount for article (SH-A)' },
);
});
it('rejects foreign articles and invalid input', async () => {
const order = await makeOrder();
await assert.rejects(
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-UNKNOWN', amount: 1 }] }),
{ message: `Article (SH-UNKNOWN) is not part of order (${order.number})` },
);
await assert.rejects(() => store.shipment.create(gid, {}), { message: 'Order is required' });
await assert.rejects(() => store.shipment.create(gid, { orderId: 0 }), { message: 'Order is required' });
await assert.rejects(() => store.shipment.create(gid, { orderId: order.id, lines: 'x' }), { message: 'Invalid lines' });
await assert.rejects(
() => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 0 }] }),
{ message: 'Invalid lines' },
);
await assert.rejects(() => store.shipment.create(gid, { orderId: 999999 }), { message: 'Order not found' });
});
it('rejects orders without lines', async () => {
const empty = await makeOrder([]);
await assert.rejects(() => store.shipment.create(gid, { orderId: empty.id }), { message: 'Order has no lines' });
});
it('cannot ship orders of another company', async () => {
const order = await makeOrder();
await assert.rejects(
() => store.shipment.create(other.googleAuthId, { orderId: order.id }),
{ message: 'Order not found' },
);
});
it('honours custom state and external number', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, {
orderId: order.id,
lines: [{ sku: 'SH-A', amount: 1 }],
state: 'delivered',
externalNumber: ' DHL-77 ',
});
assert.equal(s.state, 'delivered');
assert.equal(s.externalNumber, 'DHL-77');
});
});
describe('order.createShipment', () => {
it('is equivalent to shipment.create with the order id', async () => {
const order = await makeOrder();
const s = await store.order.createShipment(gid, order.id, { lines: [{ sku: 'SH-B', amount: 2 }] });
assert.equal(s.number, `${order.number}-L-1`);
assert.deepEqual(s.lines.map((l) => [l.sku, l.amount]), [['SH-B', 2]]);
});
});
describe('getById / listByOrderId', () => {
it('returns shipments with lines and packages', async () => {
const order = await makeOrder();
const s1 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
const s2 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-B', amount: 1 }] });
const carrier = await store.carrier.create(gid, { name: `DHL ${order.number}`, hasPackages: true });
await store.shipping.create(gid, { manifestId: s1.id, carrierId: carrier.id, type: 'parcel', state: 'draft', trackingCode: 'T-1' });
const fetched = await store.shipment.getById(gid, s1.id);
assert.equal(fetched.id, s1.id);
assert.equal(fetched.lines.length, 1);
assert.equal(fetched.packageCount, 1);
assert.equal(fetched.shippings[0].trackingCode, 'T-1');
assert.equal(fetched.shippings[0].carrier, carrier.name);
const byOrder = await store.shipment.listByOrderId(gid, order.id);
assert.deepEqual(byOrder.map((s) => s.id), [s1.id, s2.id]);
assert.equal(byOrder[0].shippings.length, 1);
assert.equal(byOrder[1].shippings.length, 0);
});
it('rejects unknown ids, foreign access and invalid ids', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
await assert.rejects(() => store.shipment.getById(gid, 999999), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.getById(other.googleAuthId, s.id), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.getById(gid, 'x'), { message: 'Shipment is required' });
await assert.rejects(() => store.shipment.listByOrderId(gid, 999999), { message: 'Order not found' });
await assert.rejects(() => store.shipment.listByOrderId(other.googleAuthId, order.id), { message: 'Order not found' });
});
});
describe('update', () => {
it('changes the state and validates it', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
assert.equal((await store.shipment.update(gid, s.id, { state: 'delivered' })).state, 'delivered');
assert.equal((await store.shipment.update(gid, s.id, { state: ' RETURNED ' })).state, 'returned');
assert.equal((await store.shipment.update(gid, s.id, {})).state, 'returned', 'empty patch keeps the state');
await assert.rejects(() => store.shipment.update(gid, s.id, { state: 'lost' }), { message: 'Invalid state' });
await assert.rejects(() => store.shipment.update(gid, 999999, { state: 'new' }), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.update(other.googleAuthId, s.id, { state: 'new' }), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.update(gid, 0, { state: 'new' }), { message: 'Shipment is required' });
});
});
describe('delete', () => {
it('deletes an unshipped shipment and reopens the order quantity', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] });
const summary = () => ctx.provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order.id);
assert.equal(summary().shipped_quantity, 10);
await store.shipment.delete(gid, s.id);
await assert.rejects(() => store.shipment.getById(gid, s.id), { message: 'Shipment not found' });
assert.equal(summary().shipped_quantity, 0);
const again = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] });
assert.ok(again.id);
});
it('refuses to delete a shipment with shipped packages', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 2 }] });
const carrier = await store.carrier.create(gid, { name: `Carrier ${order.number}`, hasPackages: true });
const pkg = await store.shipping.create(gid, { manifestId: s.id, carrierId: carrier.id, type: 'parcel', state: 'draft' });
await store.shipping.update(gid, pkg.id, { state: 'shipped' });
await assert.rejects(() => store.shipment.delete(gid, s.id), { message: 'Shipped shipment cannot be deleted' });
assert.ok(await store.shipment.getById(gid, s.id));
});
it('rejects unknown, foreign and invalid ids', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
await assert.rejects(() => store.shipment.delete(gid, 999999), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.delete(other.googleAuthId, s.id), { message: 'Shipment not found' });
await assert.rejects(() => store.shipment.delete(gid, null), { message: 'Shipment is required' });
assert.ok(await store.shipment.getById(gid, s.id));
});
});
describe('list / rebuildSummaries', () => {
let lt;
let o1;
let o2;
let s1;
let s2;
let s3;
before(async () => {
lt = await createTenant(store, { name: 'Shipment List GmbH' });
const cust = await createCustomer(store, lt, 'Listy McList');
await createArticle(store, lt, { sku: 'LS-A' });
const mk = (number) => store.order.create(lt.googleAuthId, {
number, date: '2026-10-12', entityId: cust.id,
lines: [{ sku: 'LS-A', amount: 10, net: 1, taxrate: 19 }],
});
o1 = await mk('LIST-1');
o2 = await mk('LIST-2');
s1 = await store.shipment.create(lt.googleAuthId, { orderId: o1.id, lines: [{ sku: 'LS-A', amount: 3 }] });
s2 = await store.shipment.create(lt.googleAuthId, { orderId: o1.id, lines: [{ sku: 'LS-A', amount: 2 }] });
s3 = await store.shipment.create(lt.googleAuthId, { orderId: o2.id, lines: [{ sku: 'LS-A', amount: 4 }] });
});
it('lists with summary data and pagination', async () => {
const res = await store.shipment.list(lt.googleAuthId, { offset: 0, limit: 10 });
assert.equal(res.total, 3);
assert.equal(res.unfilteredTotal, 3);
assert.equal(res.limit, 10);
assert.equal(res.offset, 0);
const row = res.shipments.find((s) => s.id === s1.id);
assert.equal(row.orderNumber, 'LIST-1');
assert.equal(row.orderId, o1.id);
assert.equal(row.state, 'new');
assert.equal(row.positions, 1);
assert.equal(row.totalQuantity, 3);
const page = await store.shipment.list(lt.googleAuthId, { offset: 2, limit: 1 });
assert.equal(page.shipments.length, 1);
assert.equal(page.total, 3);
});
it('returns a plain array without options', async () => {
const all = await store.shipment.list(lt.googleAuthId);
assert.ok(Array.isArray(all));
assert.equal(all.length, 3);
});
it('filters by order, search and state', async () => {
const byOrder = await store.shipment.list(lt.googleAuthId, { orderId: o1.id });
assert.deepEqual(byOrder.shipments.map((s) => s.id).sort(), [s1.id, s2.id].sort());
const bySearch = await store.shipment.list(lt.googleAuthId, { search: 'LIST-2' });
assert.deepEqual(bySearch.shipments.map((s) => s.id), [s3.id]);
assert.equal(bySearch.unfilteredTotal, 3);
await store.shipment.update(lt.googleAuthId, s1.id, { state: 'delivered' });
const delivered = await store.shipment.list(lt.googleAuthId, { state: 'delivered' });
assert.deepEqual(delivered.shipments.map((s) => s.id), [s1.id]);
});
it('validates options', async () => {
const l = (o) => store.shipment.list(lt.googleAuthId, o);
await assert.rejects(() => l({ offset: -1 }), { message: 'Invalid offset' });
await assert.rejects(() => l({ limit: 0 }), { message: 'Invalid limit' });
await assert.rejects(() => l({ seekMonths: 1.5 }), { message: 'Invalid seekMonths' });
});
it('rebuildSummaries preserves state and quantities', async () => {
await store.shipment.update(lt.googleAuthId, s2.id, { state: 'returned' });
await store.shipment.rebuildSummaries(lt.googleAuthId);
const returned = await store.shipment.list(lt.googleAuthId, { state: 'returned' });
assert.deepEqual(returned.shipments.map((s) => s.id), [s2.id]);
assert.equal(returned.shipments[0].totalQuantity, 2);
assert.equal(returned.shipments[0].orderNumber, 'LIST-1');
});
it('does not leak shipments of other companies', async () => {
const mine = await store.shipment.list(gid, { limit: 1000, offset: 0 });
assert.ok(!mine.shipments.some((s) => [s1.id, s2.id, s3.id].includes(s.id)));
});
});
});