Files
MixedAssets/server/smoke-test.js

1902 lines
114 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { spawn } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const port = 3999;
const baseUrl = `http://127.0.0.1:${port}`;
// Run against an isolated, freshly-seeded database so the test is deterministic
// and never reads or mutates the developer's working data.
const dbPath = path.join(os.tmpdir(), `deprecore-smoke-${Date.now()}.sqlite`);
const child = spawn(process.execPath, ['server/index.js'], {
cwd: process.cwd(),
env: { ...process.env, PORT: String(port), DEPRECORE_DB_PATH: dbPath },
stdio: ['ignore', 'pipe', 'pipe']
});
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${path} failed: ${response.status} ${JSON.stringify(body)}`);
}
return body;
}
async function waitForServer() {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
await request('/api/health');
return;
} catch {
await wait(250);
}
}
throw new Error('Server did not start');
}
const pngDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
async function main() {
await waitForServer();
const login = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({
email: 'admin@deprecore.local',
password: process.env.DEPRECORE_ADMIN_PASSWORD || 'ChangeMe123!'
})
});
const auth = { Authorization: `Bearer ${login.token}` };
if (!Array.isArray(login.capabilities) || !login.capabilities.includes('*')) {
throw new Error('Login did not return admin capabilities');
}
const profile = await request('/api/profile', { headers: auth });
if (profile.preferences.theme !== 'depreCoreDark') {
throw new Error(`Expected default dark theme, found ${profile.preferences.theme}`);
}
if (!Array.isArray(profile.capabilities) || !profile.capabilities.includes('*')) {
throw new Error('Profile did not return capabilities');
}
const updatedProfile = await request('/api/profile', {
method: 'PUT',
headers: auth,
body: JSON.stringify({ preferences: { theme: 'depreCoreDark' } })
});
if (updatedProfile.preferences.theme !== 'depreCoreDark') {
throw new Error('Profile theme preference did not persist');
}
const taxRules = await request('/api/tax-rules', { headers: auth });
const activeRule = taxRules.ruleSets.find((rule) => rule.active) || taxRules.ruleSets[0];
const methodCount = activeRule?.rules_json?.methods?.length || 0;
if (methodCount !== 74) {
throw new Error(`Expected 74 depreciation methods, found ${methodCount}`);
}
const suffix = Date.now().toString().slice(-6);
const accessControl = await request('/api/access-control', { headers: auth });
if (!accessControl.roles.some((r) => r.key === 'admin') || !accessControl.capabilities.some((c) => c.key === 'admin.users')) {
throw new Error('Access control did not return roles + capability catalog');
}
// Built-in PM roles exist with the expected split.
const pmAdmin = accessControl.roles.find((r) => r.key === 'pm_admin');
const pmUser = accessControl.roles.find((r) => r.key === 'pm_user');
if (!pmAdmin || !pmAdmin.capabilities.includes('pm.manage')) throw new Error('PM Admin role missing or lacks pm.manage');
if (!pmUser || pmUser.capabilities.includes('pm.manage') || !pmUser.capabilities.includes('pm.complete')) {
throw new Error('PM User role should complete PM but not manage plans');
}
// Custom role: create, assign to a user, enforce its capabilities, then guard deletion.
const customRole = await request('/api/roles', {
method: 'POST', headers: auth,
body: JSON.stringify({ name: `Inspector ${suffix}`, description: 'Read + PM completion', capabilities: ['assets.view', 'pm.view', 'pm.complete'] })
});
const customRoleKey = customRole.role.key;
if (!customRoleKey || customRole.role.capabilities.includes('assets.delete')) throw new Error('Custom role was not created with sanitized capabilities');
const roleUser = await request('/api/users', {
method: 'POST', headers: auth,
body: JSON.stringify({ name: `Inspector User ${suffix}`, email: `inspector-${suffix}@example.com`, password: 'ChangeMe123!', role: customRoleKey, status: 'active' })
});
if (roleUser.user.role !== customRoleKey) throw new Error('User was not assigned the custom role');
// Sign in as the custom-role user and verify enforcement.
const roleLogin = await request('/api/auth/login', {
method: 'POST', body: JSON.stringify({ email: `inspector-${suffix}@example.com`, password: 'ChangeMe123!' })
});
if (roleLogin.capabilities.includes('*') || !roleLogin.capabilities.includes('pm.complete')) throw new Error('Custom-role login capabilities incorrect');
const roleAuth = { Authorization: `Bearer ${roleLogin.token}` };
// Allowed: viewing assets. Denied: creating an asset (needs assets.edit) and managing settings.
await request('/api/assets', { headers: roleAuth });
const deniedCreate = await fetch(`${baseUrl}/api/assets`, {
method: 'POST', headers: { ...roleAuth, 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'nope', category: 'Computer Equipment' })
});
if (deniedCreate.status !== 403) throw new Error('Custom role without assets.edit should be denied asset creation');
const deniedSettings = await fetch(`${baseUrl}/api/settings`, { headers: roleAuth });
if (deniedSettings.status !== 403) throw new Error('Custom role without admin.settings should be denied settings');
// Editing the role grants a new capability that immediately takes effect on next login.
await request(`/api/roles/${customRoleKey}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ capabilities: ['assets.view', 'assets.edit', 'pm.view', 'pm.complete'] })
});
const reLogin = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: `inspector-${suffix}@example.com`, password: 'ChangeMe123!' }) });
const reAuth = { Authorization: `Bearer ${reLogin.token}` };
const nowAllowed = await fetch(`${baseUrl}/api/assets`, {
method: 'POST', headers: { ...reAuth, 'Content-Type': 'application/json' }, body: JSON.stringify({ description: `role asset ${suffix}`, category: 'Computer Equipment' })
});
if (nowAllowed.status !== 201) throw new Error('Role capability change did not take effect');
// Deleting a role in use is blocked; built-in roles cannot be deleted.
const blockedRoleDelete = await fetch(`${baseUrl}/api/roles/${customRoleKey}`, { method: 'DELETE', headers: auth });
if (blockedRoleDelete.status !== 400) throw new Error('Deleting a role in use should be blocked');
const blockedSystemDelete = await fetch(`${baseUrl}/api/roles/admin`, { method: 'DELETE', headers: auth });
if (blockedSystemDelete.status !== 400) throw new Error('Built-in roles must not be deletable');
const teamResult = await request('/api/teams', {
method: 'POST',
headers: auth,
body: JSON.stringify({
name: `Smoke Team ${suffix}`,
description: 'Smoke test RBAC team'
})
});
const renamedTeam = await request(`/api/teams/${teamResult.team.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({ name: `Smoke Team ${suffix} (edited)`, description: 'edited' })
});
if (renamedTeam.team.name !== `Smoke Team ${suffix} (edited)`) throw new Error('Team update did not persist');
const tmpTeam = await request('/api/teams', { method: 'POST', headers: auth, body: JSON.stringify({ name: `Tmp Team ${suffix}` }) });
const okDelete = await fetch(`${baseUrl}/api/teams/${tmpTeam.team.id}`, { method: 'DELETE', headers: auth });
if (okDelete.status !== 204) throw new Error('Deleting an empty team failed');
const userResult = await request('/api/users', {
method: 'POST',
headers: auth,
body: JSON.stringify({
team_id: teamResult.team.id,
name: 'Smoke Test Viewer',
email: `viewer-${suffix}@example.com`,
password: 'ChangeMe123!',
role: 'viewer',
status: 'active'
})
});
const updatedUser = await request(`/api/users/${userResult.user.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({
team_id: teamResult.team.id,
name: 'Smoke Test Operator',
email: `operator-${suffix}@example.com`,
role: 'operations',
status: 'active'
})
});
if (updatedUser.user.role !== 'operations' || updatedUser.user.team_id !== teamResult.team.id) {
throw new Error('User role or team update did not persist');
}
const blockedDelete = await fetch(`${baseUrl}/api/teams/${teamResult.team.id}`, { method: 'DELETE', headers: auth });
if (blockedDelete.status !== 400) throw new Error('Deleting a team with members should be blocked');
await request('/api/templates', {
method: 'POST',
headers: auth,
body: JSON.stringify({
name: `Smoke template ${suffix}`,
description: 'Template custom field smoke test',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'macrs_gds_200db_5' },
custom_fields: [
{ key: 'employee_id', label: 'Employee ID', type: 'text', required: true },
{ key: 'calibration_due', label: 'Calibration due', type: 'date', required: false }
]
})
});
const templates = await request('/api/templates', { headers: auth });
const smokeTemplate = templates.templates.find((template) => template.name === `Smoke template ${suffix}`);
if (!smokeTemplate || smokeTemplate.custom_fields.length !== 2) {
throw new Error('Template custom fields were not saved or returned correctly');
}
if (smokeTemplate.asset_count !== 0) throw new Error('New template should report zero assigned assets');
// Update the template.
const updatedTemplate = await request(`/api/templates/${smokeTemplate.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({
name: `Smoke template ${suffix} (edited)`,
description: 'edited',
defaults: smokeTemplate.defaults,
custom_fields: [{ key: 'employee_id', label: 'Employee ID', type: 'text', required: true }]
})
});
if (updatedTemplate.template.name !== `Smoke template ${suffix} (edited)`) throw new Error('Template update did not persist');
// Delete a disposable template and confirm it reports unlinked-asset impact.
const disposable = await request('/api/templates', {
method: 'POST', headers: auth,
body: JSON.stringify({ name: `Disposable template ${suffix}`, defaults: {}, custom_fields: [] })
});
const tmplDelete = await request(`/api/templates/${disposable.template.id}`, { method: 'DELETE', headers: auth });
if (!tmplDelete.deleted || typeof tmplDelete.unlinked_assets !== 'number') throw new Error('Template delete did not report unlink impact');
const afterDelete = await request('/api/templates', { headers: auth });
if (afterDelete.templates.some((t) => t.id === disposable.template.id)) throw new Error('Deleted template still present');
// Asset categories: create, list, rename (cascades to assets), delete.
const newCategory = await request('/api/asset-categories', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `Smoke Category ${suffix}`, code: 'SMK' })
});
if (!newCategory.category?.id || newCategory.category.asset_count !== 0) throw new Error('Asset category was not created');
const dupCategory = await fetch(`${baseUrl}/api/asset-categories`, {
method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `Smoke Category ${suffix}` })
});
if (dupCategory.status !== 400) throw new Error('Duplicate category should be rejected');
// An asset using the category, to prove rename cascades.
const catAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({ asset_id: `CAT-${suffix}`, description: 'Category test', category: `Smoke Category ${suffix}`, acquisition_cost: 100 })
});
const renamedCategory = await request(`/api/asset-categories/${newCategory.category.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ name: `Smoke Category ${suffix} (renamed)` })
});
if (renamedCategory.renamed_assets < 1) throw new Error('Category rename did not cascade to assets');
const renamedAsset = await request(`/api/assets/${catAsset.asset.id}`, { headers: auth });
if (renamedAsset.asset.category !== `Smoke Category ${suffix} (renamed)`) throw new Error('Asset category was not updated by rename');
const categoryList = await request('/api/asset-categories', { headers: auth });
const listedCategory = categoryList.categories.find((c) => c.id === newCategory.category.id);
if (!listedCategory || listedCategory.asset_count !== 1) throw new Error('Category usage count was not reported');
const catDelete = await request(`/api/asset-categories/${newCategory.category.id}`, { method: 'DELETE', headers: auth });
if (!catDelete.deleted || catDelete.asset_count !== 1) throw new Error('Category delete did not report usage impact');
// IRS asset-class reference catalog is available for pre-populating class numbers.
const irs = await request('/api/asset-classes', { headers: auth });
if (!Array.isArray(irs.assetClasses) || irs.assetClasses.length < 280) throw new Error('IRS asset-class catalog was not returned');
const computers = irs.assetClasses.find((c) => c.description === 'Computers' && c.table === 'common');
if (!computers || computers.class_number !== '00.12' || computers.gds !== 5) throw new Error('IRS common catalog entry (Computers) was incorrect');
const semiconductor = irs.assetClasses.find((c) => c.description === 'Semiconductor Manufacturing Equipment' && c.table === 'industry');
if (!semiconductor || semiconductor.class_number !== '36.1') throw new Error('IRS manufacturing-industry catalog entry was missing');
const construction = irs.assetClasses.find((c) => c.description === 'Construction' && c.table === 'business');
if (!construction || construction.class_number !== '15.0') throw new Error('IRS business-activity catalog entry was missing');
if (!irs.assetClasses[0].id) throw new Error('Asset classes should be managed records with ids');
// Asset classes are now managed: create, edit, delete.
const newClass = await request('/api/asset-classes', {
method: 'POST', headers: auth,
body: JSON.stringify({ description: `Smoke Class ${suffix}`, class_number: `99.${suffix.slice(-2)}`, gds: 7, ads: 12, source: 'custom' })
});
if (!newClass.assetClass?.id || newClass.assetClass.source !== 'custom') throw new Error('Asset class was not created');
const editedClass = await request(`/api/asset-classes/${newClass.assetClass.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ gds: 5 })
});
if (editedClass.assetClass.gds !== 5) throw new Error('Asset class update did not persist');
const classDelete = await fetch(`${baseUrl}/api/asset-classes/${newClass.assetClass.id}`, { method: 'DELETE', headers: auth });
if (classDelete.status !== 204) throw new Error('Asset class delete failed');
// Asset class number: shared by every asset in the category, and cascades on change.
const classCat = await request('/api/asset-categories', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `Class Cat ${suffix}`, asset_class_number: 'AC-100' })
});
if (classCat.category.asset_class_number !== 'AC-100') throw new Error('Category asset class number was not stored');
const classAsset = await request('/api/assets', {
method: 'POST', headers: auth, body: JSON.stringify({ asset_id: `CLS-${suffix}`, description: 'Class test', category: `Class Cat ${suffix}`, acquisition_cost: 50 })
});
if (classAsset.asset.asset_class_number !== 'AC-100') throw new Error('Asset did not inherit the category class number');
// Changing the category's class number cascades to its assets.
await request(`/api/asset-categories/${classCat.category.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ asset_class_number: 'AC-200' })
});
const reclassed = await request(`/api/assets/${classAsset.asset.id}`, { headers: auth });
if (reclassed.asset.asset_class_number !== 'AC-200') throw new Error('Class number change did not cascade to assets');
// Asset class collision: 00.12 is BOTH "Computers" (common) and "Casinos" (business). Linking a
// category by the class's unique id must resolve to the exact class, not the first number match.
const computersClass = irs.assetClasses.find((c) => c.description === 'Computers' && c.class_number === '00.12');
const casinoClass = irs.assetClasses.find((c) => c.description === 'Casinos' && c.class_number === '00.12');
if (!computersClass || !casinoClass || computersClass.id === casinoClass.id) {
throw new Error('Expected distinct Computers/Casinos classes sharing class number 00.12');
}
const computerCat = await request('/api/asset-categories', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `Computers ${suffix}`, asset_class_id: computersClass.id })
});
if (computerCat.category.asset_class_id !== computersClass.id) throw new Error('Category did not store the unique asset_class_id');
if (computerCat.category.asset_class_number !== '00.12') throw new Error('Category did not derive the class number from the linked class');
if (!/Computers/.test(computerCat.category.asset_class_label) || /Casino/.test(computerCat.category.asset_class_label)) {
throw new Error(`Category resolved to the wrong class: ${computerCat.category.asset_class_label}`);
}
// Re-point the same category to the casino class by id; the label must follow the id, not the number.
const repointed = await request(`/api/asset-categories/${computerCat.category.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ asset_class_id: casinoClass.id })
});
if (!/Casino/.test(repointed.category.asset_class_label) || repointed.category.asset_class_id !== casinoClass.id) {
throw new Error('Re-pointing category by id did not resolve to the casino class');
}
// A manual number with no id stays unlinked (asset_class_id null) but keeps the number.
const manualClassCat = await request(`/api/asset-categories/${computerCat.category.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ asset_class_id: null, asset_class_number: '99.99' })
});
if (manualClassCat.category.asset_class_id !== null || manualClassCat.category.asset_class_number !== '99.99') {
throw new Error('Manual class number override did not clear the id link');
}
// Asset ID templates: pattern, preview, assign to a category, auto-increment on asset creation.
const idTemplate = await request('/api/asset-id-templates', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `Tag ${suffix}`, pattern: 'T-A#####', next_number: 5 })
});
if (idTemplate.template.preview !== 'T-A00005') throw new Error('ID template preview was not formatted correctly');
const badPattern = await fetch(`${baseUrl}/api/asset-id-templates`, {
method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'bad', pattern: 'NOHASH' })
});
if (badPattern.status !== 400) throw new Error('Pattern without # should be rejected');
// A category that uses the template.
const tagCategory = await request('/api/asset-categories', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `Tagged Cat ${suffix}`, id_template_id: idTemplate.template.id })
});
if (tagCategory.category.id_template_id !== idTemplate.template.id) throw new Error('Category did not store its ID template assignment');
// New asset with no explicit asset_id should get the templated tag and advance the counter.
const auto1 = await request('/api/assets', {
method: 'POST', headers: auth, body: JSON.stringify({ description: 'Auto tag 1', category: `Tagged Cat ${suffix}`, acquisition_cost: 10 })
});
if (auto1.asset.asset_id !== 'T-A00005') throw new Error(`Expected auto tag T-A00005, got ${auto1.asset.asset_id}`);
const auto2 = await request('/api/assets', {
method: 'POST', headers: auth, body: JSON.stringify({ description: 'Auto tag 2', category: `Tagged Cat ${suffix}`, acquisition_cost: 10 })
});
if (auto2.asset.asset_id !== 'T-A00006') throw new Error(`Expected auto tag T-A00006, got ${auto2.asset.asset_id}`);
// An explicit asset_id still wins over the template.
const explicit = await request('/api/assets', {
method: 'POST', headers: auth, body: JSON.stringify({ asset_id: `EXP-${suffix}`, description: 'Explicit', category: `Tagged Cat ${suffix}`, acquisition_cost: 10 })
});
if (explicit.asset.asset_id !== `EXP-${suffix}`) throw new Error('Explicit asset_id should override the template');
// Deleting the template unassigns it from the category.
const idTplDelete = await request(`/api/asset-id-templates/${idTemplate.template.id}`, { method: 'DELETE', headers: auth });
if (!idTplDelete.deleted || idTplDelete.category_count !== 1) throw new Error('ID template delete did not unassign its category');
// Asset departments: create, rename (cascades to assets), usage count, delete.
const newDept = await request('/api/asset-departments', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `Smoke Dept ${suffix}`, code: 'SMKD' })
});
if (!newDept.department?.id) throw new Error('Asset department was not created');
await request(`/api/assets/${catAsset.asset.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ department: `Smoke Dept ${suffix}` })
});
const renamedDept = await request(`/api/asset-departments/${newDept.department.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ name: `Smoke Dept ${suffix} (renamed)` })
});
if (renamedDept.renamed_assets < 1) throw new Error('Department rename did not cascade to assets');
const deptAsset = await request(`/api/assets/${catAsset.asset.id}`, { headers: auth });
if (deptAsset.asset.department !== `Smoke Dept ${suffix} (renamed)`) throw new Error('Asset department was not updated by rename');
const deptList = await request('/api/asset-departments', { headers: auth });
if (!deptList.departments.some((d) => d.id === newDept.department.id && d.asset_count === 1)) throw new Error('Department usage count was not reported');
const deptDelete = await request(`/api/asset-departments/${newDept.department.id}`, { method: 'DELETE', headers: auth });
if (!deptDelete.deleted || deptDelete.asset_count !== 1) throw new Error('Department delete did not report usage impact');
const assetResult = await request('/api/assets', {
method: 'POST',
headers: auth,
body: JSON.stringify({
asset_id: `T-${suffix}`,
description: 'Smoke test workstation',
category: 'Computer Equipment',
acquisition_cost: 2400,
acquired_date: '2026-01-05',
in_service_date: '2026-01-05',
useful_life_months: 60,
depreciation_method: 'macrs_gds_200db_5',
books: [
{ book_type: 'GAAP', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, cost: 2400 },
{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, section_179_amount: 500, bonus_percent: 0, cost: 2400 }
]
})
});
const assetId = assetResult.asset.id;
// Per-book persistence: GAAP and FEDERAL must keep distinct methods.
const hydrated = await request(`/api/assets/${assetId}`, { headers: auth });
const gaapBook = hydrated.asset.books.find((book) => book.book_type === 'GAAP');
const federalBook = hydrated.asset.books.find((book) => book.book_type === 'FEDERAL');
if (gaapBook?.depreciation_method !== 'straight_line' || federalBook?.depreciation_method !== 'macrs_gds_200db_5') {
throw new Error('Per-book depreciation methods did not persist independently');
}
if (Number(federalBook.section_179_amount) !== 500) {
throw new Error('Per-book Section 179 amount did not persist');
}
// File cabinet (multipart upload bypasses the JSON request helper)
const uploadForm = new FormData();
uploadForm.append('file', new Blob(['invoice contents'], { type: 'text/plain' }), 'invoice.txt');
const uploadResponse = await fetch(`${baseUrl}/api/assets/${assetId}/files`, { method: 'POST', headers: auth, body: uploadForm });
const fileResult = await uploadResponse.json();
if (!uploadResponse.ok || !fileResult.file?.id) throw new Error('Asset file upload did not return a file');
const fileDownload = await fetch(`${baseUrl}/api/assets/${assetId}/files/${fileResult.file.id}`, { headers: auth });
if (!fileDownload.ok || (await fileDownload.text()) !== 'invoice contents') {
throw new Error('Asset file download did not return stored contents');
}
// Warranty
const warrantyResult = await request(`/api/assets/${assetId}/warranties`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ provider: 'Acme Care', start_date: '2026-01-05', expiration_date: '2029-01-05', coverage_details: 'Parts and labor' })
});
if (!warrantyResult.warranty?.id) throw new Error('Warranty was not created');
// Task lifecycle
const taskResult = await request(`/api/assets/${assetId}/tasks`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ title: 'Calibrate device', type: 'calibration', due_date: '2026-03-01' })
});
const completedTask = await request(`/api/assets/${assetId}/tasks/${taskResult.task.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({ status: 'complete' })
});
if (completedTask.task.status !== 'complete') throw new Error('Task status update did not persist');
// Notes
await request(`/api/assets/${assetId}/notes`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ body: 'Received and tagged in receiving dock.' })
});
// Identification photos (base64 + caption)
const photoResult = await request(`/api/assets/${assetId}/photos`, {
method: 'POST', headers: auth,
body: JSON.stringify({ name: 'front.png', mime: 'image/png', caption: 'Front nameplate', data: pngDataUrl })
});
if (!photoResult.photo?.id) throw new Error('Asset photo was not created');
const recaptioned = await request(`/api/assets/${assetId}/photos/${photoResult.photo.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ caption: 'Serial label, left side' })
});
if (recaptioned.photo.caption !== 'Serial label, left side') throw new Error('Asset photo caption did not update');
const detailed = await request(`/api/assets/${assetId}`, { headers: auth });
if (!detailed.asset.files.length || !detailed.asset.warranties.length || !detailed.asset.notes_log.length) {
throw new Error('Asset detail collections (files/warranties/notes) were not returned');
}
if (!detailed.asset.photos.length || !detailed.asset.photos[0].data || detailed.asset.photos[0].caption !== 'Serial label, left side') {
throw new Error('Asset photos (with caption + data) were not returned');
}
// Barcode lookup: set a barcode value, then resolve it via the scan endpoint.
await request(`/api/assets/${assetId}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({ barcode_value: `BC-${suffix}` })
});
const scanned = await request(`/api/assets/lookup?code=BC-${suffix}`, { headers: auth });
if (scanned.asset.id !== assetId) throw new Error('Barcode lookup did not resolve the scanned asset');
const byAssetId = await request(`/api/assets/lookup?code=${encodeURIComponent(assetResult.asset.asset_id)}`, { headers: auth });
if (byAssetId.asset.id !== assetId) throw new Error('Asset ID lookup fallback failed');
// Reporting suite: catalog, several report types, export, and saved reports.
const reportCatalog = await request('/api/reports/catalog', { headers: auth });
if (!reportCatalog.reports.length || !reportCatalog.params.book) {
throw new Error('Report catalog did not return reports and param specs');
}
const runReportApi = (type, options) => request('/api/reports/run', {
method: 'POST',
headers: auth,
body: JSON.stringify({ type, options })
});
const acq = await runReportApi('acquisitions', { year: 2026 });
if (!Array.isArray(acq.report.rows)) throw new Error('Acquisitions report did not return rows');
const disp = await runReportApi('disposals', { year: 2026 });
if (!Array.isArray(disp.report.rows)) throw new Error('Disposals report did not return rows');
const roll = await runReportApi('rollforward', { book: 'GAAP', year: 2026 });
if (!roll.report.columns.length) throw new Error('Rollforward report did not return columns');
const f4797 = await runReportApi('form_4797', { year: 2026 });
if (!Array.isArray(f4797.report.rows)) throw new Error('Form 4797 report did not return rows');
const custom = await runReportApi('custom', { columns: ['asset_id', 'acquisition_cost'] });
if (custom.report.columns.length !== 2) throw new Error('Report builder column projection failed');
for (const pmReport of ['pm_due', 'pm_uncompleted', 'pm_completed', 'alerts_since']) {
const r = await runReportApi(pmReport, { before_date: '2099-01-01', since_date: '2000-01-01' });
if (!Array.isArray(r.report.rows)) throw new Error(`${pmReport} report did not return rows`);
}
const exportResponse = await fetch(`${baseUrl}/api/reports/export`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'GAAP', year: 2026 }, format: 'csv' })
});
if (!exportResponse.ok || !(await exportResponse.text()).includes('Asset ID')) {
throw new Error('Report CSV export failed');
}
const savedReport = await request('/api/saved-reports', {
method: 'POST',
headers: auth,
body: JSON.stringify({ name: `Saved ${suffix}`, report_type: 'net_book_value', options: { book: 'GAAP', year: 2026 } })
});
const savedList = await request('/api/saved-reports', { headers: auth });
if (!savedList.reports.some((report) => report.id === savedReport.report.id)) {
throw new Error('Saved report was not persisted/listed');
}
// Tax rule sets: expand (method catalog), create, edit, activate, delete.
const expanded = await request('/api/tax-rules/expand', {
method: 'POST',
headers: auth,
body: JSON.stringify({ rules_json: { jurisdiction: 'US-SMOKE', version: '1.0' } })
});
if ((expanded.rules_json.methods || []).length !== 74) {
throw new Error('Rule set expansion did not generate the 74-method catalog');
}
const createdRule = await request('/api/tax-rules', {
method: 'POST',
headers: auth,
body: JSON.stringify({
jurisdiction: `US-SMOKE-${suffix}`,
name: 'Smoke rule set',
version: '1.0',
effective_date: '2026-01-01',
active: false,
rules_json: { jurisdiction: `US-SMOKE-${suffix}`, version: '1.0', limits: { section179: { deductionLimit: 1000000 } } }
})
});
const editedRule = await request(`/api/tax-rules/${createdRule.ruleSet.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({ name: 'Smoke rule set (edited)', rules_json: { jurisdiction: `US-SMOKE-${suffix}`, version: '1.0', limits: { section179: { deductionLimit: 2000000 } } } })
});
if (editedRule.ruleSet.name !== 'Smoke rule set (edited)' || editedRule.ruleSet.rules_json.limits.section179.deductionLimit !== 2000000) {
throw new Error('Tax rule set edit did not persist');
}
const activated = await request(`/api/tax-rules/${createdRule.ruleSet.id}/activate`, { method: 'POST', headers: auth });
if (!activated.ruleSets.find((set) => set.id === createdRule.ruleSet.id)?.active) {
throw new Error('Tax rule set activation did not persist');
}
const deleteRule = await fetch(`${baseUrl}/api/tax-rules/${createdRule.ruleSet.id}`, { method: 'DELETE', headers: auth });
if (deleteRule.status !== 204) throw new Error('Tax rule set delete failed');
// Books registry: list, edit (assign rule set + default method), and GL ledger.
const booksList = await request('/api/books', { headers: auth });
if (booksList.books.length < 6 || !Array.isArray(booksList.ruleSets)) {
throw new Error('Books registry did not return the standard books and rule sets');
}
const gaapBookDef = booksList.books.find((book) => book.code === 'GAAP');
const federalRuleSet = booksList.ruleSets[0];
const editedBook = await request(`/api/books/${gaapBookDef.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({ default_method: 'straight_line', tax_rule_set_id: federalRuleSet.id, enabled: true })
});
if (editedBook.book.tax_rule_set_id !== federalRuleSet.id) {
throw new Error('Book tax-rule assignment did not persist');
}
// User-defined book: create, ensure it applies to a new asset, then guard + delete.
const newBook = await request('/api/books', {
method: 'POST',
headers: auth,
body: JSON.stringify({ code: `IFRS${suffix}`.slice(0, 12), name: 'IFRS book', book_type: 'financial', enabled: true })
});
const newBookCode = newBook.book.code;
const assetWithUserBook = await request('/api/assets', {
method: 'POST',
headers: auth,
body: JSON.stringify({ asset_id: `UB-${suffix}`, description: 'User-book asset', category: 'Computer Equipment', acquisition_cost: 1200 })
});
const hydratedUserBook = await request(`/api/assets/${assetWithUserBook.asset.id}`, { headers: auth });
if (!hydratedUserBook.asset.books.some((b) => b.book_type === newBookCode)) {
throw new Error('Newly created enabled book did not apply to a new asset');
}
const blockedBookDelete = await fetch(`${baseUrl}/api/books/${newBook.book.id}`, { method: 'DELETE', headers: auth });
if (blockedBookDelete.status !== 400) throw new Error('Deleting a book in use should be blocked');
const spareBook = await request('/api/books', {
method: 'POST', headers: auth, body: JSON.stringify({ code: `TMP${suffix}`.slice(0, 12), name: 'Temp book' })
});
const okBookDelete = await fetch(`${baseUrl}/api/books/${spareBook.book.id}`, { method: 'DELETE', headers: auth });
if (okBookDelete.status !== 204) throw new Error('Deleting an unused book failed');
const ledger = await request('/api/books/GAAP/ledger?year=2026', { headers: auth });
if (!ledger.ledger.accounts.length || typeof ledger.ledger.summary.nbv !== 'number') {
throw new Error('Book GL ledger did not return accounts and summary');
}
const ledgerExport = await fetch(`${baseUrl}/api/books/GAAP/ledger/export`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ year: 2026, format: 'csv' })
});
if (!ledgerExport.ok || !(await ledgerExport.text()).includes('GL account')) {
throw new Error('Book GL ledger export failed');
}
// Alerts: a clearly-overdue task should raise a critical alert on scan.
await request(`/api/assets/${assetId}/tasks`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ title: 'Annual inspection', type: 'inspection', due_date: '2020-01-01' })
});
const scan = await request('/api/alerts/scan', { method: 'POST', headers: auth });
const overdue = scan.alerts.find((a) => a.type === 'maintenance_overdue' && a.due_date === '2020-01-01');
if (!overdue || overdue.severity !== 'critical') throw new Error('Overdue maintenance alert was not raised');
const acked = await request(`/api/alerts/${overdue.id}/acknowledge`, { method: 'PUT', headers: auth });
if (acked.alert.status !== 'acknowledged') throw new Error('Alert acknowledge did not persist');
const dismissed = await request(`/api/alerts/${overdue.id}/dismiss`, { method: 'PUT', headers: auth });
if (dismissed.alert.status !== 'dismissed') throw new Error('Alert dismiss did not persist');
// Notification (SMTP) settings: password is write-only and never returned.
await request('/api/notifications/settings', {
method: 'PUT',
headers: auth,
body: JSON.stringify({
smtp_host: 'smtp.example.com', smtp_port: 587, smtp_secure: false,
smtp_user: 'mailer@example.com', smtp_password: 'secret', smtp_from: 'DepreCore <no-reply@example.com>',
alerts_enabled: false, alerts_lead_days: 45, alerts_recipients: 'ops@example.com, finance@example.com'
})
});
const notif = await request('/api/notifications/settings', { headers: auth });
if (notif.settings.smtp_host !== 'smtp.example.com' || notif.settings.smtp_password_set !== true || notif.settings.smtp_password !== undefined) {
throw new Error('Notification settings did not persist or leaked the password');
}
if (notif.settings.alerts_lead_days !== 45) throw new Error('Alert lead days did not persist');
const adminSettings = await request('/api/settings', { headers: auth });
if ('smtp_password' in adminSettings.settings) throw new Error('Admin settings endpoint leaked smtp_password');
// Preventative maintenance: plan with steps, attach to asset, alert, complete, settings.
const pmPlan = await request('/api/pm-plans', {
method: 'POST',
headers: auth,
body: JSON.stringify({
name: `Lift PM ${suffix}`,
frequency_value: 3,
frequency_unit: 'months',
steps: [{ title: 'Tighten lug bolt A3', est_minutes: 15 }, { title: 'Inspect hydraulics', est_minutes: 30 }],
components: [
{ part_number: 'OIL-10', description: 'Hydraulic oil', supplier: 'Acme', cost: 40 },
{ part_number: 'FLT-2', description: 'Filter', supplier: 'Acme', cost: 12 }
],
photos: [{ name: 'diagram.png', mime: 'image/png', caption: 'Lug bolt locations', data: pngDataUrl }]
})
});
if (pmPlan.plan.steps.length !== 2) throw new Error('PM plan steps were not saved');
if (pmPlan.plan.estimated_minutes !== 45) throw new Error('Estimated minutes were not totaled from step times');
if (pmPlan.plan.components.length !== 2 || pmPlan.plan.components_total !== 52) throw new Error('PM components were not saved/totaled');
if (pmPlan.plan.photos.length !== 1 || !pmPlan.plan.photos[0].data) throw new Error('PM plan guidance photo was not saved');
// PM categories: create, rename (cascades to plans), usage count, delete — separate from asset categories.
const pmCat = await request('/api/pm-categories', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `PM Cat ${suffix}`, code: 'PMC' })
});
if (!pmCat.category?.id) throw new Error('PM category was not created');
await request(`/api/pm-plans/${pmPlan.plan.id}`, { method: 'PUT', headers: auth, body: JSON.stringify({ category: `PM Cat ${suffix}` }) });
const renamedPmCat = await request(`/api/pm-categories/${pmCat.category.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ name: `PM Cat ${suffix} (renamed)` })
});
if (renamedPmCat.renamed_plans < 1) throw new Error('PM category rename did not cascade to plans');
const renamedPlan = await request(`/api/pm-plans/${pmPlan.plan.id}`, { headers: auth });
if (renamedPlan.plan.category !== `PM Cat ${suffix} (renamed)`) throw new Error('PM plan category was not updated by rename');
const pmCatList = await request('/api/pm-categories', { headers: auth });
if (!pmCatList.categories.some((c) => c.id === pmCat.category.id && c.plan_count === 1)) throw new Error('PM category usage count was not reported');
// PM categories are distinct from asset categories.
const assetCats = await request('/api/asset-categories', { headers: auth });
if (assetCats.categories.some((c) => c.name === `PM Cat ${suffix} (renamed)`)) throw new Error('PM category leaked into asset categories');
const pmCatDelete = await request(`/api/pm-categories/${pmCat.category.id}`, { method: 'DELETE', headers: auth });
if (!pmCatDelete.deleted || pmCatDelete.plan_count !== 1) throw new Error('PM category delete did not report usage impact');
// The list view omits photo blobs; the single-plan fetch includes them.
const planList = await request('/api/pm-plans', { headers: auth });
const listed = planList.plans.find((p) => p.id === pmPlan.plan.id);
if (listed.photo_count !== 1 || (listed.photos[0] && listed.photos[0].data)) throw new Error('PM plan list should report photo_count without blobs');
const singlePlan = await request(`/api/pm-plans/${pmPlan.plan.id}`, { headers: auth });
if (!singlePlan.plan.photos[0].data) throw new Error('Single PM plan fetch should include photo data');
// Editing the plan keeps the existing photo (resent by id) and updates its caption.
const editedPlan = await request(`/api/pm-plans/${pmPlan.plan.id}`, {
method: 'PUT', headers: auth,
body: JSON.stringify({ photos: [{ id: pmPlan.plan.photos[0].id, caption: 'Updated caption' }] })
});
if (editedPlan.plan.photos.length !== 1 || editedPlan.plan.photos[0].caption !== 'Updated caption' || !editedPlan.plan.photos[0].data) {
throw new Error('Editing PM plan photo caption (resend by id) did not preserve the blob');
}
const planPrint = await request('/api/reports/run', { method: 'POST', headers: auth, body: JSON.stringify({ type: 'pm_plan', options: { pm_plan_id: pmPlan.plan.id } }) });
if (planPrint.report.rows.length !== 2 || !planPrint.report.meta.note.includes('Frequency')) {
throw new Error('Printable PM plan report was incomplete');
}
const attached = await request(`/api/assets/${assetId}/pm`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ plan_id: pmPlan.plan.id, frequency_value: 1, frequency_unit: 'months', start_date: '2020-01-01', next_due_date: '2020-01-01', end_date: '2035-01-01' })
});
const pmScan = await request('/api/alerts/scan', { method: 'POST', headers: auth });
if (!pmScan.alerts.some((a) => a.type === 'pm_overdue' && a.reference_id === attached.pm.id)) {
throw new Error('Overdue PM alert was not raised');
}
// Guidance photos surface on the asset's PM schedule for the completion view.
const pmGuidance = await request(`/api/assets/${assetId}/pm`, { headers: auth });
const pmSchedule = pmGuidance.pm.find((p) => p.id === attached.pm.id);
if (!pmSchedule.plan_photos || !pmSchedule.plan_photos.length || !pmSchedule.plan_photos[0].data) {
throw new Error('PM plan guidance photos were not exposed on the asset schedule');
}
const completed = await request(`/api/assets/${assetId}/pm/${attached.pm.id}/complete`, {
method: 'POST',
headers: auth,
body: JSON.stringify({
steps: [{ title: 'Tighten lug bolt A3', done: true }],
notes_list: ['Topped up fluids', 'Replaced worn filter'],
ratings: { safety: 5, physical: 4, operating: 5 },
signature: pngDataUrl,
photos: [{ name: 'after.png', mime: 'image/png', data: pngDataUrl }]
})
});
if (!completed.pm.next_due_date || completed.pm.next_due_date <= '2020-01-01') {
throw new Error('Completing PM did not advance the next due date');
}
// Signature is required.
const missingSig = await fetch(`${baseUrl}/api/assets/${assetId}/pm/${attached.pm.id}/complete`, {
method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ ratings: { safety: 3 } })
});
if (missingSig.status !== 400) throw new Error('PM completion without a signature should be rejected');
const withPm = await request(`/api/assets/${assetId}`, { headers: auth });
if (!withPm.asset.pm.length || !withPm.asset.pm[0].completions.length) {
throw new Error('Asset PM schedule/completions were not returned');
}
if (withPm.asset.pm[0].total_cost !== 52) {
throw new Error('PM completion cost (from components) was not captured');
}
const firstCompletion = withPm.asset.pm[0].completions[0];
if (!firstCompletion.has_signature || firstCompletion.photo_count !== 1 || firstCompletion.ratings.safety !== 5) {
throw new Error('PM completion metadata (signature/photos/ratings) was not stored');
}
const completionList = await request(`/api/pm-completions?asset_id=${assetId}`, { headers: auth });
if (!completionList.completions.length) throw new Error('PM completions list was empty');
const completionDetail = await request(`/api/pm-completions/${completionList.completions[0].id}`, { headers: auth });
if (!completionDetail.completion.signature || !completionDetail.completion.photos.length || completionDetail.completion.notes.length !== 2) {
throw new Error('PM completion form detail was incomplete');
}
// PM book: actual maintenance spend aggregated per asset.
const booksWithPm = await request('/api/books', { headers: auth });
if (!booksWithPm.books.some((b) => b.code === 'PM')) throw new Error('PM book missing from the registry');
const yearNow = new Date().getFullYear();
const pmLedger = await request(`/api/books/PM/ledger?year=${yearNow}`, { headers: auth });
if (pmLedger.ledger.kind !== 'maintenance' || pmLedger.ledger.summary.cost < 52) {
throw new Error('PM book ledger did not aggregate maintenance cost');
}
await request('/api/pm-settings', {
method: 'PUT',
headers: auth,
body: JSON.stringify({ pm_default_frequency_value: 6, pm_default_frequency_unit: 'months', pm_lead_days: 14 })
});
const pmSettings = await request('/api/pm-settings', { headers: auth });
if (pmSettings.settings.pm_default_frequency_value !== 6 || pmSettings.settings.pm_lead_days !== 14) {
throw new Error('PM settings did not persist');
}
// ---- Dynamic PM scheduling: usage meters + lifespan wear curve --------------
// Turn on both dynamic signals.
await request('/api/pm-settings', {
method: 'PUT', headers: auth,
body: JSON.stringify({ pm_usage_tracking_enabled: true, pm_lifespan_curve_enabled: true, pm_lifespan_min_factor: 0.5, pm_lifespan_curve_exponent: 1 })
});
const dynSettings = await request('/api/pm-settings', { headers: auth });
if (!dynSettings.settings.pm_usage_tracking_enabled || !dynSettings.settings.pm_lifespan_curve_enabled) {
throw new Error('Dynamic PM settings did not persist');
}
// A plan that tracks a 500-unit meter (e.g. operating hours).
const meterPlan = await request('/api/pm-plans', {
method: 'POST', headers: auth,
body: JSON.stringify({
name: `Usage Plan ${suffix}`, frequency_value: 12, frequency_unit: 'months',
meters: [{ label: 'Operating Hours', unit: 'hrs', usage_interval: 500 }]
})
});
if (!meterPlan.plan.meters || meterPlan.plan.meters.length !== 1 || Number(meterPlan.plan.meters[0].usage_interval) !== 500) {
throw new Error('Plan meter was not stored');
}
// A brand-new asset so the lifespan curve does NOT compress yet.
const youngAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({ asset_id: `PM-YOUNG-${suffix}`, description: 'Young pump', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: new Date().toISOString().slice(0, 10), useful_life_months: 60 })
});
const youngId = youngAsset.asset.id;
const dueIn12mo = await request(`/api/assets/${youngId}/pm`, {
method: 'POST', headers: auth,
body: JSON.stringify({ plan_id: meterPlan.plan.id, frequency_value: 12, frequency_unit: 'months', lifespan_adjust: true })
});
const youngApId = dueIn12mo.pm.id;
const youngMeterId = (await request(`/api/assets/${youngId}/pm`, { headers: auth })).pm.find((p) => p.id === youngApId).meters[0].id;
// Complete with a baseline reading; threshold resets to baseline + 500.
const today = new Date().toISOString().slice(0, 10);
const baseDue = (await request(`/api/assets/${youngId}/pm/${youngApId}/complete`, {
method: 'POST', headers: auth,
body: JSON.stringify({ signature: pngDataUrl, completed_at: today, meter_readings: [{ asset_pm_meter_id: youngMeterId, reading: 1000 }] })
})).pm.next_due_date;
// A young asset on a 12-month calendar with no usage rate yet → ~12 months out (curve barely moves it).
if (!baseDue || baseDue <= today) throw new Error('Composite next-due did not advance after completion');
// Log a reading 10 days later implying ~30 hrs/day → reaches 1500 (the threshold) well before 12 months.
const tenDays = new Date(Date.now() + 10 * 86400000).toISOString().slice(0, 10);
const afterReading = await request(`/api/assets/${youngId}/pm/${youngApId}/readings`, {
method: 'POST', headers: auth,
body: JSON.stringify({ asset_pm_meter_id: youngMeterId, reading: 1300, reading_date: tenDays })
});
if (!(afterReading.pm.next_due_date < baseDue)) {
throw new Error('Fast usage rate did not pull the next-due date in ahead of the calendar date');
}
// Push the meter past its threshold → a usage-driven overdue PM alert.
await request(`/api/assets/${youngId}/pm/${youngApId}/readings`, {
method: 'POST', headers: auth,
body: JSON.stringify({ asset_pm_meter_id: youngMeterId, reading: 1600, reading_date: tenDays })
});
const usageScan = await request('/api/alerts/scan', { method: 'POST', headers: auth });
const usageAlert = usageScan.alerts.find((a) => a.reference_id === youngApId && /Usage threshold/.test(a.message || ''));
if (!usageAlert) throw new Error('Usage-threshold PM alert was not raised');
// Lifespan curve: an end-of-life asset compresses the same interval vs the young one.
const oldDate = new Date(Date.now() - 58 * 30 * 86400000).toISOString().slice(0, 10); // ~58 months old of a 60-month life
const oldAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({ asset_id: `PM-OLD-${suffix}`, description: 'Old pump', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: oldDate, useful_life_months: 60 })
});
const oldAttach = await request(`/api/assets/${oldAsset.asset.id}/pm`, {
method: 'POST', headers: auth,
body: JSON.stringify({ plan_id: meterPlan.plan.id, frequency_value: 12, frequency_unit: 'months', lifespan_adjust: true })
});
const oldMeterId = (await request(`/api/assets/${oldAsset.asset.id}/pm`, { headers: auth })).pm.find((p) => p.id === oldAttach.pm.id).meters[0].id;
const oldDue = (await request(`/api/assets/${oldAsset.asset.id}/pm/${oldAttach.pm.id}/complete`, {
method: 'POST', headers: auth,
body: JSON.stringify({ signature: pngDataUrl, completed_at: today, meter_readings: [{ asset_pm_meter_id: oldMeterId, reading: 1000 }] })
})).pm.next_due_date;
if (!(oldDue < baseDue)) {
throw new Error('Lifespan curve did not compress the interval for an end-of-life asset');
}
// Nightly recompute job: persist a manually-stale next_due_date, then sweep it back to "today"
// because the meter is already past its threshold (usage projection → today).
await request(`/api/assets/${youngId}/pm/${youngApId}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ next_due_date: '2099-01-01' })
});
const recompute = await request('/api/pm-recompute', { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (!recompute.result || recompute.result.updated < 1) throw new Error('Manual recompute did not update any schedules');
if (!recompute.settings.pm_last_recompute_at) throw new Error('Recompute did not stamp last-run time');
const sweptSchedule = (await request(`/api/assets/${youngId}/pm`, { headers: auth })).pm.find((p) => p.id === youngApId);
if (sweptSchedule.next_due_date >= '2099-01-01') {
throw new Error('Recompute did not refresh the stale projected due-date');
}
// Recompute settings persist.
await request('/api/pm-settings', { method: 'PUT', headers: auth, body: JSON.stringify({ pm_recompute_enabled: true, pm_recompute_hour: 3 }) });
const recomputeSettings = await request('/api/pm-settings', { headers: auth });
if (!recomputeSettings.settings.pm_recompute_enabled || recomputeSettings.settings.pm_recompute_hour !== 3) {
throw new Error('Recompute settings did not persist');
}
// Contacts directory: typed records, type-specific fields, filtering, notes.
const vendorContact = await request('/api/contacts', {
method: 'POST',
headers: auth,
body: JSON.stringify({
type: 'vendor', organization: `Acme Supply ${suffix}`, email: `sales-${suffix}@acme.test`,
phone: '+1 555-0100', rating: 4, credit_term: 'net_60', country: 'US'
})
});
if (vendorContact.contact.type !== 'vendor' || vendorContact.contact.rating !== 4 || vendorContact.contact.credit_term !== 'net_60') {
throw new Error('Vendor contact fields did not persist');
}
await request('/api/contacts', {
method: 'POST',
headers: auth,
body: JSON.stringify({
type: 'contractor', first_name: 'Dana', last_name: 'Cruz', email: `dana-${suffix}@contractor.test`,
tax_id: 'BL-99821', department: 'Facilities'
})
});
const vendorList = await request('/api/contacts?type=vendor', { headers: auth });
if (!vendorList.contacts.some((c) => c.id === vendorContact.contact.id) || vendorList.contacts.some((c) => c.type !== 'vendor')) {
throw new Error('Contact type filter failed');
}
const updatedContact = await request(`/api/contacts/${vendorContact.contact.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({ rating: 5 })
});
if (updatedContact.contact.rating !== 5) throw new Error('Contact update did not persist');
await request(`/api/contacts/${vendorContact.contact.id}/notes`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ body: 'Negotiated faster lead times.' })
});
const contactDetail = await request(`/api/contacts/${vendorContact.contact.id}`, { headers: auth });
if (!contactDetail.contact.contact_notes.length) throw new Error('Contact notes were not returned');
const employeeResult = await request('/api/employees', {
method: 'POST',
headers: auth,
body: JSON.stringify({
employee_number: `EMP-${suffix}`,
name: 'Smoke Test Employee',
email: `employee-${suffix}@example.com`,
department: 'Operations',
location: 'Headquarters'
})
});
await request('/api/assignments', {
method: 'POST',
headers: auth,
body: JSON.stringify({
asset_id: assetResult.asset.id,
employee_id: employeeResult.employee.id,
notes: 'Smoke assignment'
})
});
const assignments = await request('/api/assignments?active=true', { headers: auth });
if (!assignments.assignments.some((assignment) => assignment.asset_id === assetResult.asset.id && assignment.employee_id === employeeResult.employee.id)) {
throw new Error('Asset assignment was not saved or returned correctly');
}
const workday = await request('/api/workday/connection', {
method: 'PUT',
headers: auth,
body: JSON.stringify({
name: 'Smoke Workday',
tenant: 'smoke',
base_url: 'https://example.workday.com',
token_url: 'https://example.workday.com/oauth2/token',
client_id: 'client',
client_secret: 'secret',
workers_path: '/workers',
enabled: false,
sync_enabled: true,
sync_interval_hours: 12
})
});
if (!workday.connection.configured || workday.connection.sync_enabled !== true || workday.connection.sync_interval_hours !== 12) {
throw new Error('Workday connection configuration did not persist');
}
await request('/api/workday/import-workers', {
method: 'POST',
headers: auth,
body: JSON.stringify({
workers: [{ id: `WD-${suffix}`, firstName: 'Pat', lastName: 'Lee', email: `pat-${suffix}@corp.test`, department: 'IT', managerName: 'Sam Ray', hireDate: '2024-02-01' }]
})
});
const importedContacts = await request(`/api/contacts?type=employee&search=pat-${suffix}`, { headers: auth });
if (!importedContacts.contacts.some((c) => c.workday_worker_id === `WD-${suffix}` && c.start_date === '2024-02-01' && c.manager_last_name === 'Ray')) {
throw new Error('Workday import did not create an enriched employee contact');
}
const report = await request('/api/reports/depreciation?year=2026&book=GAAP', { headers: auth });
if (!Array.isArray(report.rows) || !report.rows.length) {
throw new Error('Depreciation report returned no rows');
}
// 200% declining-balance methods. MF200 (MACRS) ignores salvage and reproduces the IRS
// 5-year table; DB200 (standard) honors salvage (recovers only cost salvage).
const round = (n) => Math.round(Number(n));
const macrsAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `MF-${suffix}`, description: 'MF200 press', category: 'Computer Equipment',
acquisition_cost: 10000, salvage_value: 1000, in_service_date: '2026-01-01', useful_life_months: 60,
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MF200', convention: 'half_year', useful_life_months: 60, cost: 10000 }]
})
});
const mfSched = (await request(`/api/assets/${macrsAsset.asset.id}/depreciation?startYear=2026&endYear=2032`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL');
const mfByYear = Object.fromEntries(mfSched.map((r) => [r.year, round(r.depreciation)]));
// IRS 5-year 200%DB half-year: 2000, 3200, 1920, 1152, 1152, 576 — salvage is ignored.
if (mfByYear[2026] !== 2000 || mfByYear[2027] !== 3200 || mfByYear[2028] !== 1920) {
throw new Error(`MF200 schedule incorrect: ${JSON.stringify(mfByYear)}`);
}
const mfTotal = round(mfSched.reduce((s, r) => s + r.depreciation, 0));
if (mfTotal !== 10000) throw new Error(`MF200 should recover full cost (MACRS ignores salvage), got ${mfTotal}`);
const dbAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `DB-${suffix}`, description: 'DB200 press', category: 'Computer Equipment',
acquisition_cost: 10000, salvage_value: 1000, in_service_date: '2026-01-01', useful_life_months: 60,
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'DB200', convention: 'half_year', useful_life_months: 60, cost: 10000 }]
})
});
const dbSched = (await request(`/api/assets/${dbAsset.asset.id}/depreciation?startYear=2026&endYear=2034`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL');
const dbTotal = round(dbSched.reduce((s, r) => s + r.depreciation, 0));
if (dbTotal !== 9000) throw new Error(`DB200 should recover cost salvage (9000), got ${dbTotal}`);
if (dbSched.some((r) => r.netBookValue < 999)) throw new Error('DB200 depreciated below salvage value');
// New York Liberty Zone: seeded zone applies a 30% §168 allowance to the federal book in year 1.
const zoneList = await request('/api/depreciation-zones', { headers: auth });
const liberty = zoneList.zones.find((z) => z.code === 'ny_liberty');
if (!liberty || liberty.allowance_percent !== 30) throw new Error('New York Liberty Zone was not seeded');
const zoneAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `LZ-${suffix}`, description: 'Liberty Zone equipment', category: 'Computer Equipment',
acquisition_cost: 10000, in_service_date: '2005-01-01', useful_life_months: 60, special_zone: 'ny_liberty',
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MF200', convention: 'half_year', useful_life_months: 60, cost: 10000 }]
})
});
const lzSched = (await request(`/api/assets/${zoneAsset.asset.id}/depreciation?startYear=2005&endYear=2011`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL');
const lzByYear = Object.fromEntries(lzSched.map((r) => [r.year, round(r.depreciation)]));
// 30% allowance (3000) + first-year MACRS (20% half-year) on the remaining 7000 (1400) = 4400.
if (lzByYear[2005] !== 4400) throw new Error(`Liberty Zone year-1 allowance incorrect: ${JSON.stringify(lzByYear)}`);
// Outside the placed-in-service window (2008) the allowance must NOT apply.
const outAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `LZX-${suffix}`, description: 'Out-of-window', category: 'Computer Equipment',
acquisition_cost: 10000, in_service_date: '2008-01-01', useful_life_months: 60, special_zone: 'ny_liberty',
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MF200', convention: 'half_year', useful_life_months: 60, cost: 10000 }]
})
});
const outYear1 = round((await request(`/api/assets/${outAsset.asset.id}/depreciation?startYear=2008&endYear=2008`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
if (outYear1 !== 2000) throw new Error(`Out-of-window asset should get plain MACRS year-1 (2000), got ${outYear1}`);
// Section 179 limits + Enterprise Zone: the seeded 2015 limit ($500,000) caps a plain asset's
// §179, but an Enterprise Zone asset gets the +$35,000 increase, letting a larger §179 through.
const limitList = await request('/api/section-179-limits', { headers: auth });
if (!limitList.limits.some((l) => l.tax_year === 2015 && l.base_limit === 500000)) {
throw new Error('2015 §179 limit was not seeded');
}
const ezList = await request('/api/depreciation-zones', { headers: auth });
const ez = ezList.zones.find((z) => z.code === 'enterprise_zone');
if (!ez || ez.section_179_increase !== 35000 || ez.section_179_cost_factor !== 0.5) {
throw new Error('Enterprise Zone was not seeded with the §179 increase/cost factor');
}
// Plain asset (no zone): §179 entered 520k is capped at the 500k base limit. Straight-line, 5yr,
// half-year → basis 100k, year-1 SL 10k → year-1 = 500000 + 10000 = 510000.
const s179Plain = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `S179-${suffix}`, description: '§179 capped', category: 'Computer Equipment',
acquisition_cost: 600000, in_service_date: '2015-01-01', useful_life_months: 60,
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 520000, cost: 600000 }]
})
});
const plainY1 = round((await request(`/api/assets/${s179Plain.asset.id}/depreciation?startYear=2015&endYear=2015`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
if (plainY1 !== 510000) throw new Error(`§179 should cap at the 500k base limit (year-1 510000), got ${plainY1}`);
// Enterprise Zone asset: limit 500k + 35k = 535k, so the full 520k §179 applies. basis 80k,
// year-1 SL 8k → year-1 = 520000 + 8000 = 528000.
const s179Ez = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `S179EZ-${suffix}`, description: '§179 enterprise zone', category: 'Computer Equipment',
acquisition_cost: 600000, in_service_date: '2015-01-01', useful_life_months: 60, special_zone: 'enterprise_zone',
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 520000, cost: 600000 }]
})
});
const ezY1 = round((await request(`/api/assets/${s179Ez.asset.id}/depreciation?startYear=2015&endYear=2015`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
if (ezY1 !== 528000) throw new Error(`Enterprise Zone §179 increase should allow 520k (year-1 528000), got ${ezY1}`);
// Gulf Opportunity (GO) Zone: combines a 50% §168 allowance AND a +$100,000 §179 increase.
const go = ezList.zones.find((z) => z.code === 'go_zone');
if (!go || go.allowance_percent !== 50 || go.section_179_increase !== 100000 || go.section_179_threshold_increase !== 600000) {
throw new Error('GO Zone was not seeded with the 50% allowance + §179 increase/threshold');
}
// 2007 base §179 limit is $125,000; GO Zone raises it to $225,000, so the entered $200,000 §179
// applies in full. Then the 50% §168 allowance hits the post-§179 basis, plus first-year SL.
// §179 200000 + bonus 50%×(600000200000)=200000 + SL ((600000200000200000)/5)×0.5=20000 = 420000.
const goAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `GO-${suffix}`, description: 'GO Zone equipment', category: 'Computer Equipment',
acquisition_cost: 600000, in_service_date: '2007-06-01', useful_life_months: 60, special_zone: 'go_zone',
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 200000, cost: 600000 }]
})
});
const goY1 = round((await request(`/api/assets/${goAsset.asset.id}/depreciation?startYear=2007&endYear=2007`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
if (goY1 !== 420000) throw new Error(`GO Zone year-1 (50% allowance + §179 increase) should be 420000, got ${goY1}`);
// A GO Zone asset placed in service in 2010 is past the §179 window (ends 2008) but still within
// the §168 allowance window (to 2011). It must get the 50% allowance but NOT the §179 increase:
// entered $550,000 §179 is capped at the 2010 base limit of $500,000 (not $600,000).
// §179 500000 + bonus 50%×(600000500000)=50000 + SL ((60000050000050000)/5)×0.5=5000 = 555000.
const goLate = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `GOL-${suffix}`, description: 'GO Zone late', category: 'Computer Equipment',
acquisition_cost: 600000, in_service_date: '2010-06-01', useful_life_months: 60, special_zone: 'go_zone',
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 550000, cost: 600000 }]
})
});
const goLateY1 = round((await request(`/api/assets/${goLate.asset.id}/depreciation?startYear=2010&endYear=2010`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
if (goLateY1 !== 555000) throw new Error(`GO Zone 2010 asset: §179 increase expired but allowance applies (year-1 555000), got ${goLateY1}`);
// Kansas Disaster Zone (Qualified Recovery Assistance): 50% §168 allowance + $100,000 §179 increase,
// §168 and §179 windows coincide (2007-05-05 to 2008-12-31). Same shape as the GO Zone 2007 case.
const kz = ezList.zones.find((z) => z.code === 'kansas_disaster');
if (!kz || kz.allowance_percent !== 50 || kz.section_179_increase !== 100000 || kz.section_179_threshold_increase !== 600000) {
throw new Error('Kansas Disaster Zone was not seeded with the 50% allowance + §179 increase/threshold');
}
const kzAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `KZ-${suffix}`, description: 'Kansas recovery equipment', category: 'Computer Equipment',
acquisition_cost: 600000, in_service_date: '2007-06-01', useful_life_months: 60, special_zone: 'kansas_disaster',
books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, section_179_amount: 200000, cost: 600000 }]
})
});
const kzY1 = round((await request(`/api/assets/${kzAsset.asset.id}/depreciation?startYear=2007&endYear=2007`, { headers: auth }))
.schedules.filter((r) => r.book === 'FEDERAL')[0].depreciation);
// §179 200000 (limit raised 125k→225k) + 50% allowance 200000 + SL 20000 = 420000.
if (kzY1 !== 420000) throw new Error(`Kansas Disaster Zone year-1 (50% allowance + §179 increase) should be 420000, got ${kzY1}`);
// Disposal: preview gain/loss, then record a full disposal.
const disposalAsset = await request('/api/assets', {
method: 'POST',
headers: auth,
body: JSON.stringify({
asset_id: `D-${suffix}`,
description: 'Disposal test press',
category: 'Computer Equipment',
acquisition_cost: 10000,
acquired_date: '2022-01-05',
in_service_date: '2022-01-05',
useful_life_months: 60,
depreciation_method: 'straight_line'
})
});
const preview = await request(`/api/assets/${disposalAsset.asset.id}/disposal/preview`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ book_type: 'GAAP', disposal_date: '2026-06-01', disposal_price: 3000, disposal_expense: 200 })
});
if (typeof preview.preview.gain_loss !== 'number' || typeof preview.preview.net_book_value !== 'number') {
throw new Error('Disposal preview did not return gain/loss figures');
}
const disposed = await request(`/api/assets/${disposalAsset.asset.id}/disposals`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ book_type: 'GAAP', disposal_date: '2026-06-01', disposal_price: 3000, disposal_expense: 200, method: 'sale', property_type: '1245' })
});
if (disposed.asset.status !== 'disposed' || !disposed.disposal?.id) {
throw new Error('Full disposal did not mark the asset disposed');
}
// Impairment adjustment
const adjustment = await request(`/api/assets/${disposalAsset.asset.id}/adjustments`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ type: 'impairment', amount: 500, book_type: 'GAAP', reason: 'Flood damage' })
});
if (!adjustment.adjustment?.id) throw new Error('Adjustment was not recorded');
// Revaluation adjustment (feeds the revaluation reports)
const reval = await request(`/api/assets/${disposalAsset.asset.id}/adjustments`, {
method: 'POST', headers: auth,
body: JSON.stringify({ type: 'revaluation', amount: 800, book_type: 'GAAP', reason: 'Market revaluation' })
});
if (!reval.adjustment?.id) throw new Error('Revaluation adjustment was not recorded');
// Abandonment disposal (counts as a write-off) on a fresh asset
const abandonAsset = await request('/api/assets', {
method: 'POST', headers: auth,
body: JSON.stringify({
asset_id: `WO-${suffix}`, description: 'Abandoned unit', category: 'Computer Equipment',
acquisition_cost: 5000, acquired_date: '2023-01-01', in_service_date: '2023-01-01', useful_life_months: 60, depreciation_method: 'straight_line'
})
});
const abandoned = await request(`/api/assets/${abandonAsset.asset.id}/disposals`, {
method: 'POST', headers: auth,
body: JSON.stringify({ book_type: 'GAAP', disposal_date: '2026-06-15', disposal_price: 0, disposal_expense: 0, method: 'abandonment' })
});
if (!abandoned.disposal?.id) throw new Error('Abandonment disposal was not recorded');
// New report families: catalog presence, summaries, journals, transactions, history, dossier.
const newCatalogKeys = (await request('/api/reports/catalog', { headers: auth })).reports.map((r) => r.key);
for (const key of ['asset_journal', 'fixed_asset_journal', 'journal_revaluation', 'journal_writeoff', 'txn_revaluations',
'txn_writeoffs', 'txn_purchases', 'txn_disposals', 'asset_summary', 'depreciation_summary', 'ytd_depreciation_summary',
'purchase_summary', 'disposal_summary', 'revaluation_summary', 'writeoff_summary', 'asset_history', 'future_depreciation',
'journal_depreciation_monthly', 'journal_disposals_monthly']) {
if (!newCatalogKeys.includes(key)) throw new Error(`Report catalog missing ${key}`);
}
const revalSummary = await runReportApi('revaluation_summary', { year: 2026 });
if (!revalSummary.report.rows.some((r) => /revaluation/i.test(r.type))) throw new Error('Revaluation summary did not include the revaluation');
const writeoffTxn = await runReportApi('txn_writeoffs', { year: 2026 });
const woSources = writeoffTxn.report.rows.map((r) => r.source);
if (!woSources.includes('Impairment') || !woSources.includes('Abandonment')) {
throw new Error('Write-off transactions must include both impairment and abandonment');
}
const woJournal = await runReportApi('journal_writeoff', { year: 2026 });
if (Math.round(woJournal.report.totals.debit) !== Math.round(woJournal.report.totals.credit) || woJournal.report.totals.debit <= 0) {
throw new Error('Write-off journal entry must be balanced and non-zero');
}
const assetSummaryReport = await runReportApi('asset_summary', { book: 'GAAP', year: 2026 });
if (!assetSummaryReport.report.rows.length || typeof assetSummaryReport.report.totals.count !== 'number') {
throw new Error('Asset summary did not aggregate by category');
}
const historyReport = await runReportApi('asset_history', { asset_id: disposalAsset.asset.id });
if (!historyReport.report.rows.some((r) => /disposal/i.test(r.event))) throw new Error('Asset history did not include the disposal event');
const journalReport = await runReportApi('asset_journal', { asset_id: disposalAsset.asset.id });
if (journalReport.report.meta?.layout !== 'dossier' || !journalReport.report.meta.sections.length) {
throw new Error('Asset journal did not return a dossier with sections');
}
const journalPdf = await fetch(`${baseUrl}/api/reports/export`, {
method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'asset_journal', options: { asset_id: disposalAsset.asset.id }, format: 'pdf' })
});
const journalPdfBuf = Buffer.from(await journalPdf.arrayBuffer());
if (!journalPdf.ok || journalPdf.headers.get('content-type') !== 'application/pdf' || journalPdfBuf.length < 500) {
throw new Error('Asset journal PDF export failed');
}
// Lease accounting + amortization schedule
const lease = await request('/api/leases', {
method: 'POST',
headers: auth,
body: JSON.stringify({
asset_id: assetId,
lessor: 'Equip Leasing Co',
contract_value: 24000,
start_date: '2026-01-01',
end_date: '2028-01-01',
discount_rate: 6,
payment_frequency: 'monthly'
})
});
const schedule = await request(`/api/leases/${lease.lease.id}/schedule`, { headers: auth });
if (!schedule.schedule?.rows?.length || !(schedule.schedule.summary.present_value > 0)) {
throw new Error('Lease amortization schedule was not generated');
}
if (schedule.schedule.rows.length !== schedule.schedule.summary.periods) {
throw new Error('Lease schedule period count mismatch');
}
// Parts inventory: part record, stock locations (aisle/bin), reservations, movements, photos.
const workLocation = await request('/api/contacts', {
method: 'POST',
headers: auth,
body: JSON.stringify({ type: 'work_location', organization: `Main Warehouse ${suffix}` })
});
const partResult = await request('/api/parts', {
method: 'POST',
headers: auth,
body: JSON.stringify({
part_number: `HYD-${suffix}`, name: 'Hydraulic filter', category: 'Filters',
unit_of_measure: 'each', unit_cost: 18.5, reorder_point: 5, reorder_quantity: 20,
measurements: '120mm x 80mm', supplier_contact_id: vendorContact.contact.id
})
});
const partId = partResult.part.id;
if (partResult.part.supplier_name === null) throw new Error('Part supplier name was not resolved from contacts');
const withStock = await request(`/api/parts/${partId}/stock`, {
method: 'POST',
headers: auth,
body: JSON.stringify({ location_contact_id: workLocation.contact.id, aisle: 'A12', bin: 'B3', quantity: 10, reserved: 2 })
});
const stockRow = withStock.part.stock[0];
if (!stockRow || stockRow.aisle !== 'A12' || stockRow.bin !== 'B3' || stockRow.available !== 8) {
throw new Error('Part stock location (aisle/bin/available) was not stored correctly');
}
if (withStock.part.on_hand !== 10 || withStock.part.reserved !== 2) {
throw new Error('Part on-hand/reserved aggregation failed');
}
// Receive 5 more, then reserve 3, then issue 4.
const received = await request(`/api/parts/${partId}/transactions`, {
method: 'POST', headers: auth, body: JSON.stringify({ stock_id: stockRow.id, type: 'receive', quantity: 5, notes: 'PO-1' })
});
if (received.part.on_hand !== 15) throw new Error('Receive movement did not increase on-hand');
const reserved = await request(`/api/parts/${partId}/transactions`, {
method: 'POST', headers: auth, body: JSON.stringify({ stock_id: stockRow.id, type: 'reserve', quantity: 3, asset_id: assetId })
});
if (reserved.part.reserved !== 5) throw new Error('Reserve movement did not increase reserved');
const issued = await request(`/api/parts/${partId}/transactions`, {
method: 'POST', headers: auth, body: JSON.stringify({ stock_id: stockRow.id, type: 'issue', quantity: 4 })
});
if (issued.part.on_hand !== 11 || issued.part.available !== 6) throw new Error('Issue movement did not reduce on-hand/available');
if (issued.part.transactions.length !== 3) throw new Error('Part transaction history was not recorded');
// Photo attach
const photoPart = await request(`/api/parts/${partId}/photos`, {
method: 'POST', headers: auth, body: JSON.stringify({ name: 'filter.png', mime: 'image/png', data: pngDataUrl })
});
if (!photoPart.part.photos.length) throw new Error('Part photo was not attached');
// Low-stock filter: drop below reorder point and confirm it surfaces.
await request(`/api/parts/${partId}/transactions`, {
method: 'POST', headers: auth, body: JSON.stringify({ stock_id: stockRow.id, type: 'adjust', quantity: 3 })
});
const lowStock = await request('/api/parts?low_stock=true', { headers: auth });
if (!lowStock.parts.some((p) => p.id === partId && p.low_stock)) {
throw new Error('Low-stock filter did not surface the depleted part');
}
const partList = await request(`/api/parts?search=HYD-${suffix}`, { headers: auth });
if (!partList.parts.some((p) => p.id === partId)) throw new Error('Part search did not return the created part');
// Parts categories: seeded defaults present; create, rename (cascades to parts), usage count, delete.
const seededPartCats = await request('/api/part-categories', { headers: auth });
for (const expected of ['Electrical', 'Plumbing', 'Fire Safety', 'Other']) {
if (!seededPartCats.categories.some((c) => c.name === expected)) throw new Error(`Seeded part category "${expected}" missing`);
}
const newPartCat = await request('/api/part-categories', {
method: 'POST', headers: auth, body: JSON.stringify({ name: `Part Cat ${suffix}` })
});
if (!newPartCat.category?.id) throw new Error('Part category was not created');
await request(`/api/parts/${partId}`, { method: 'PUT', headers: auth, body: JSON.stringify({ category: `Part Cat ${suffix}` }) });
const renamedPartCat = await request(`/api/part-categories/${newPartCat.category.id}`, {
method: 'PUT', headers: auth, body: JSON.stringify({ name: `Part Cat ${suffix} (renamed)` })
});
if (renamedPartCat.renamed_parts < 1) throw new Error('Part category rename did not cascade to parts');
const renamedPartDetail = await request(`/api/parts/${partId}`, { headers: auth });
if (renamedPartDetail.part.category !== `Part Cat ${suffix} (renamed)`) throw new Error('Part category was not updated by rename');
const partCatList = await request('/api/part-categories', { headers: auth });
if (!partCatList.categories.some((c) => c.id === newPartCat.category.id && c.part_count === 1)) throw new Error('Part category usage count was not reported');
const partCatDelete = await request(`/api/part-categories/${newPartCat.category.id}`, { method: 'DELETE', headers: auth });
if (!partCatDelete.deleted || partCatDelete.part_count !== 1) throw new Error('Part category delete did not report usage impact');
// Alert webhook: each new alert is delivered to an external endpoint as a JSON POST.
const webhookHits = [];
const hookSecret = 'whsec_smoke';
const hookServer = http.createServer((req, res) => {
let raw = '';
req.on('data', (chunk) => { raw += chunk; });
req.on('end', () => {
webhookHits.push({ headers: req.headers, raw, body: JSON.parse(raw || '{}') });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"ok":true}');
});
});
await new Promise((resolve) => hookServer.listen(4100, '127.0.0.1', resolve));
try {
const hookUrl = 'http://127.0.0.1:4100/hook';
const savedHook = await request('/api/notifications/settings', {
method: 'PUT', headers: auth,
body: JSON.stringify({ webhook_enabled: true, webhook_url: hookUrl, webhook_secret: hookSecret })
});
if (savedHook.settings.webhook_url !== hookUrl || savedHook.settings.webhook_secret_set !== true || savedHook.settings.webhook_secret !== undefined) {
throw new Error('Webhook settings did not persist or leaked the secret');
}
// Test event + HMAC signature verification.
const hookTest = await request('/api/notifications/webhook-test', { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (!hookTest.ok) throw new Error('Webhook test event was not delivered');
const testHit = webhookHits.find((h) => h.body.event === 'test');
if (!testHit) throw new Error('Webhook receiver did not get the test event');
const expectedSig = `sha256=${crypto.createHmac('sha256', hookSecret).update(testHit.raw).digest('hex')}`;
if (testHit.headers['x-deprecore-signature'] !== expectedSig) {
throw new Error('Webhook HMAC signature was missing or incorrect');
}
// A fresh overdue task guarantees a new open alert; the cycle should POST it.
await request(`/api/assets/${assetId}/tasks`, {
method: 'POST', headers: auth,
body: JSON.stringify({ title: `Webhook overdue ${suffix}`, type: 'inspection', due_date: '2019-01-01' })
});
const cycle = await request('/api/alerts/run', { method: 'POST', headers: auth });
if (!cycle.webhooked || !(cycle.delivered > 0)) throw new Error('Alert cycle did not deliver alerts to the webhook');
const alertHit = webhookHits.find((h) => h.body.event === 'alert');
if (!alertHit || !alertHit.body.type || !alertHit.body.severity) throw new Error('Webhook alert payload was incomplete');
} finally {
await new Promise((resolve) => hookServer.close(resolve));
}
// Microsoft Teams integration: posts alert digests to a Teams webhook (Adaptive Card / MessageCard).
const teamsHits = [];
const teamsServer = http.createServer((req, res) => {
let raw = '';
req.on('data', (chunk) => { raw += chunk; });
req.on('end', () => {
teamsHits.push({ raw, body: JSON.parse(raw || '{}') });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('1');
});
});
await new Promise((resolve) => teamsServer.listen(4300, '127.0.0.1', resolve));
try {
const teamsUrl = 'http://127.0.0.1:4300/teams';
const savedTeams = await request('/api/teams/settings', {
method: 'PUT', headers: auth,
body: JSON.stringify({ teams_enabled: true, teams_webhook_url: teamsUrl, teams_format: 'adaptive', teams_min_severity: 'info' })
});
if (savedTeams.settings.teams_webhook_url !== teamsUrl || savedTeams.settings.teams_format !== 'adaptive' || !savedTeams.settings.teams_enabled) {
throw new Error('Teams settings did not persist');
}
// Test card uses the Adaptive Card (Workflows) envelope.
const teamsTest = await request('/api/teams/test', { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (!teamsTest.ok) throw new Error('Teams test card was not delivered');
const adaptiveHit = teamsHits.find((h) => h.body.type === 'message');
if (!adaptiveHit || !Array.isArray(adaptiveHit.body.attachments) ||
adaptiveHit.body.attachments[0].contentType !== 'application/vnd.microsoft.card.adaptive' ||
adaptiveHit.body.attachments[0].content.type !== 'AdaptiveCard') {
throw new Error('Teams adaptive card envelope was malformed');
}
// Disable the (now-closed) webhook so the next cycle is handled by Teams, then raise a fresh alert.
await request('/api/notifications/settings', { method: 'PUT', headers: auth, body: JSON.stringify({ webhook_enabled: false }) });
await request(`/api/assets/${assetId}/tasks`, {
method: 'POST', headers: auth,
body: JSON.stringify({ title: `Teams overdue ${suffix}`, type: 'inspection', due_date: '2019-02-02' })
});
const teamsCycle = await request('/api/alerts/run', { method: 'POST', headers: auth });
if (!teamsCycle.teamsPosted || !(teamsCycle.teamsDelivered > 0)) throw new Error('Alert cycle did not post alerts to Teams');
const digestHit = teamsHits.find((h) => h.body.type === 'message' && JSON.stringify(h.body).includes(`Teams overdue ${suffix}`));
if (!digestHit) throw new Error('Teams did not receive the alert digest card');
// Legacy MessageCard format produces a different envelope.
await request('/api/teams/settings', { method: 'PUT', headers: auth, body: JSON.stringify({ teams_format: 'messagecard' }) });
const mcTest = await request('/api/teams/test', { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (!mcTest.ok) throw new Error('Teams MessageCard test was not delivered');
if (!teamsHits.some((h) => h.body['@type'] === 'MessageCard')) throw new Error('Teams MessageCard payload was not produced');
// Disable Teams so it doesn't interfere with later alert assertions.
await request('/api/teams/settings', { method: 'PUT', headers: auth, body: JSON.stringify({ teams_enabled: false }) });
} finally {
await new Promise((resolve) => teamsServer.close(resolve));
}
// ServiceNow integration: incident push (Table API) + CMDB asset pull, against a mock instance.
const snHits = [];
const snServer = http.createServer((req, res) => {
let raw = '';
req.on('data', (chunk) => { raw += chunk; });
req.on('end', () => {
snHits.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', raw });
res.setHeader('Content-Type', 'application/json');
if (req.url.startsWith('/api/now/table/incident') && req.method === 'POST') {
res.writeHead(201);
res.end(JSON.stringify({ result: { sys_id: 'sys-inc-123', number: 'INC0099999' } }));
} else if (req.url.includes('/api/now/table/cmdb_ci_hardware')) {
res.writeHead(200);
res.end(JSON.stringify({
result: [{
sys_id: 'ci-abc-1', name: 'Mock Server 01', asset_tag: `CMDB-${suffix}`, serial_number: `SN-${suffix}`,
sys_class_name: 'cmdb_ci_server', manufacturer: 'Dell', location: 'Data Center 1', cost: '2500', purchase_date: '2025-01-15'
}]
}));
} else {
res.writeHead(200);
res.end(JSON.stringify({ result: [] }));
}
});
});
await new Promise((resolve) => snServer.listen(4200, '127.0.0.1', resolve));
try {
const snUrl = 'http://127.0.0.1:4200';
const savedSn = await request('/api/servicenow/settings', {
method: 'PUT', headers: auth,
body: JSON.stringify({
servicenow_instance_url: snUrl, servicenow_username: 'svc_deprecore', servicenow_password: 'snpass',
servicenow_ticket_enabled: true, servicenow_cmdb_table: 'cmdb_ci_hardware'
})
});
if (savedSn.settings.servicenow_instance_url !== snUrl || savedSn.settings.servicenow_password_set !== true || savedSn.settings.servicenow_password !== undefined) {
throw new Error('ServiceNow settings did not persist or leaked the password');
}
if (!savedSn.settings.servicenow_cmdb_map || !savedSn.settings.servicenow_cmdb_map.serial_number) {
throw new Error('ServiceNow default CMDB field map was not returned');
}
// Connection test (GET incident with a basic-auth header).
const snTest = await request('/api/servicenow/test', { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (!snTest.ok) throw new Error('ServiceNow connection test failed');
if (!snHits.some((h) => h.method === 'GET' && h.auth.startsWith('Basic '))) {
throw new Error('ServiceNow request did not include basic auth');
}
// Submit an alert as an incident.
await request(`/api/assets/${assetId}/tasks`, {
method: 'POST', headers: auth,
body: JSON.stringify({ title: `ServiceNow overdue ${suffix}`, type: 'inspection', due_date: '2018-01-01' })
});
const snScan = await request('/api/alerts/scan', { method: 'POST', headers: auth });
const targetAlert = snScan.alerts.find((a) => a.status === 'open' && !a.external_ticket_number);
if (!targetAlert) throw new Error('No open alert available to ticket');
const ticketResult = await request(`/api/alerts/${targetAlert.id}/ticket`, { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (ticketResult.ticket.number !== 'INC0099999') throw new Error('ServiceNow incident number was not returned');
if (!snHits.some((h) => h.method === 'POST' && h.url.startsWith('/api/now/table/incident'))) {
throw new Error('ServiceNow incident POST was not sent');
}
// Re-submitting returns the existing ticket rather than duplicating.
const dupTicket = await request(`/api/alerts/${targetAlert.id}/ticket`, { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (!dupTicket.ticket.duplicate) throw new Error('Re-ticketing should return the existing incident');
const ticketedList = await request('/api/alerts', { headers: auth });
if (!ticketedList.alerts.some((a) => a.id === targetAlert.id && a.external_ticket_number === 'INC0099999')) {
throw new Error('Alert was not linked to its ServiceNow incident');
}
// CMDB pull: create/update assets from configuration items.
const cmdbSync = await request('/api/servicenow/cmdb-sync', { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (cmdbSync.total !== 1 || (cmdbSync.created + cmdbSync.updated) < 1) throw new Error('CMDB sync did not import the configuration item');
const cmdbAssets = await request(`/api/assets?search=CMDB-${suffix}`, { headers: auth });
const cmdbAsset = cmdbAssets.assets.find((a) => a.asset_id === `CMDB-${suffix}`);
if (!cmdbAsset || cmdbAsset.serial_number !== `SN-${suffix}` || Number(cmdbAsset.acquisition_cost) !== 2500) {
throw new Error('CMDB-sourced asset fields were not populated');
}
// A second sync should update the same asset, not duplicate it.
const reSync = await request('/api/servicenow/cmdb-sync', { method: 'POST', headers: auth, body: JSON.stringify({}) });
if (reSync.created !== 0 || reSync.updated !== 1) throw new Error('Re-syncing a CMDB CI should update, not duplicate');
} finally {
await new Promise((resolve) => snServer.close(resolve));
}
// ---- Password access control, lockout & audit trail -----------------------
// Helper that returns status + body instead of throwing, so we can assert on rejections.
async function rawRequest(p, options = {}) {
const response = await fetch(`${baseUrl}${p}`, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
const body = await response.json().catch(() => ({}));
return { status: response.status, body };
}
// Tighten the policy: long, complex, short reuse window, fast lockout. expiry_days 0 keeps the
// admin session from being forced to change mid-test.
const policyResp = await request('/api/password-policy', {
method: 'PUT', headers: auth,
body: JSON.stringify({ policy: {
min_length: 14, require_upper: true, require_lower: true, require_number: true, require_symbol: true,
disallow_identifiers: true, history_count: 3, expiry_days: 0, min_age_hours: 0,
lockout_threshold: 3, lockout_window_minutes: 5
} })
});
if (policyResp.policy.min_length !== 14 || policyResp.policy.lockout_threshold !== 3) {
throw new Error('Password policy did not persist');
}
if (!Array.isArray(policyResp.rules) || !policyResp.rules.length) throw new Error('Policy rules were not described');
const secEmail = `sec-${suffix}@example.com`;
const strongPassword = 'Str0ng!Passphrase#2024';
// A weak password is rejected by the policy.
const weak = await rawRequest('/api/users', {
method: 'POST', headers: auth,
body: JSON.stringify({ name: 'Pat Quinn', email: secEmail, password: 'short', role: 'viewer' })
});
if (weak.status !== 400) throw new Error(`Weak password should be rejected (got ${weak.status})`);
// A compliant password succeeds.
const created = await request('/api/users', {
method: 'POST', headers: auth,
body: JSON.stringify({ name: 'Pat Quinn', email: secEmail, password: strongPassword, role: 'viewer' })
});
const secUserId = created.user.id;
// The new user can sign in (admin-set real password does not force a change).
const secLogin = await request('/api/auth/login', {
method: 'POST', body: JSON.stringify({ email: secEmail, password: strongPassword })
});
if (secLogin.mustChangePassword) throw new Error('Admin-set password should not force a change');
const secAuth = { Authorization: `Bearer ${secLogin.token}` };
// Reusing the current password via self-service change is blocked by password history.
const reuse = await rawRequest('/api/profile/password', {
method: 'PUT', headers: secAuth,
body: JSON.stringify({ current_password: strongPassword, new_password: strongPassword })
});
if (reuse.status !== 400) throw new Error(`Password reuse should be rejected (got ${reuse.status})`);
// A fresh compliant password is accepted.
const newPassword = 'Rotated!Secret#2025';
const changed = await rawRequest('/api/profile/password', {
method: 'PUT', headers: secAuth,
body: JSON.stringify({ current_password: strongPassword, new_password: newPassword })
});
if (changed.status !== 200) throw new Error(`Valid password change failed (got ${changed.status} ${JSON.stringify(changed.body)})`);
// Account lockout: the threshold is 3, so the third bad attempt locks the account.
let lockedStatus = null;
for (let i = 0; i < 3; i += 1) {
const attempt = await rawRequest('/api/auth/login', {
method: 'POST', body: JSON.stringify({ email: secEmail, password: 'wrong-password' })
});
lockedStatus = attempt.status;
}
if (lockedStatus !== 423) throw new Error(`Account should lock after 3 failures (got ${lockedStatus})`);
// Even the correct password is refused while locked.
const whileLocked = await rawRequest('/api/auth/login', {
method: 'POST', body: JSON.stringify({ email: secEmail, password: newPassword })
});
if (whileLocked.status !== 423) throw new Error('Correct password should be refused while locked');
// An admin can unlock; the user can then sign in again.
await request(`/api/users/${secUserId}/unlock`, { method: 'POST', headers: auth });
const afterUnlock = await rawRequest('/api/auth/login', {
method: 'POST', body: JSON.stringify({ email: secEmail, password: newPassword })
});
if (afterUnlock.status !== 200) throw new Error(`Login after unlock failed (got ${afterUnlock.status})`);
// Audit trail: failed logins and the lockout were recorded and are queryable.
const failedLogs = await request('/api/audit-logs?action=login_failed', { headers: auth });
if (failedLogs.total < 3) throw new Error(`Expected >=3 login_failed audit rows, found ${failedLogs.total}`);
if (!failedLogs.facets || !failedLogs.facets.actions.includes('login_failed')) {
throw new Error('Audit facets did not include login_failed');
}
const lockLogs = await request('/api/audit-logs?action=account_locked', { headers: auth });
if (lockLogs.total < 1) throw new Error('account_locked event was not audited');
// CSV export returns text/csv with a header row.
const csvResp = await fetch(`${baseUrl}/api/audit-logs/export?action=login_failed`, { headers: auth });
if (!(csvResp.headers.get('content-type') || '').includes('text/csv')) throw new Error('Audit export was not text/csv');
const csvText = await csvResp.text();
if (!csvText.startsWith('id,timestamp,actor,action')) throw new Error('Audit CSV header was malformed');
// ---- Multi-company scoping -------------------------------------------------
const companyHeaders = (id) => ({ ...auth, 'X-Company-Id': String(id) });
// Company A is the seeded default; create company B.
const companyA = (await request('/api/entities', { headers: auth })).entities[0].id;
const companyBEntity = (await request('/api/entities', {
method: 'POST', headers: auth,
body: JSON.stringify({ name: `Company B ${suffix}`, base_currency: 'USD', federal_tax_id: '12-3456789', state_tax_id: 'ST-001', local_tax_id: 'LOC-9' })
})).entity;
const companyB = companyBEntity.id;
if (!companyB || companyB === companyA) throw new Error('Second company was not created');
if (companyBEntity.federal_tax_id !== '12-3456789' || companyBEntity.state_tax_id !== 'ST-001' || companyBEntity.local_tax_id !== 'LOC-9') {
throw new Error('Company federal/state/local tax ids did not persist');
}
// A new company auto-seeds its own book set (distinct ids from company A's).
const booksA = (await request('/api/books', { headers: companyHeaders(companyA) })).books;
const booksB = (await request('/api/books', { headers: companyHeaders(companyB) })).books;
if (!booksB.some((b) => b.code === 'GAAP')) throw new Error('New company did not get a seeded book set');
if (booksA.find((b) => b.code === 'GAAP').id === booksB.find((b) => b.code === 'GAAP').id) {
throw new Error('Company B GAAP book is not a distinct per-company record');
}
// Create an asset under each company via the X-Company-Id header.
const assetB = (await request('/api/assets', {
method: 'POST', headers: companyHeaders(companyB),
body: JSON.stringify({ asset_id: `B-${suffix}`, description: 'Company B asset', category: 'Computer Equipment', acquisition_cost: 5000, in_service_date: '2024-01-01' })
})).asset;
const assetAonly = (await request('/api/assets', {
method: 'POST', headers: companyHeaders(companyA),
body: JSON.stringify({ asset_id: `A-${suffix}`, description: 'Company A only', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: '2024-01-01' })
})).asset;
// Listings are scoped to the active company.
const listA = (await request('/api/assets', { headers: companyHeaders(companyA) })).assets;
const listB = (await request('/api/assets', { headers: companyHeaders(companyB) })).assets;
if (listB.some((a) => a.id === assetAonly.id)) throw new Error('Company B sees company A assets');
if (!listB.some((a) => a.id === assetB.id)) throw new Error('Company B is missing its own asset');
if (listA.some((a) => a.id === assetB.id)) throw new Error('Company A sees company B assets');
// Cross-company asset access is blocked (company A cannot fetch company B's asset).
const crossFetch = await fetch(`${baseUrl}/api/assets/${assetB.id}`, { headers: companyHeaders(companyA) });
if (crossFetch.status !== 404) throw new Error('Cross-company asset fetch should 404');
// PM plans are per-company.
await request('/api/pm-plans', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Plan ${suffix}`, frequency_value: 3, frequency_unit: 'months' }) });
const plansB = (await request('/api/pm-plans', { headers: companyHeaders(companyB) })).plans;
const plansA = (await request('/api/pm-plans', { headers: companyHeaders(companyA) })).plans;
if (!plansB.some((p) => p.name === `B Plan ${suffix}`)) throw new Error('Company B PM plan missing');
if (plansA.some((p) => p.name === `B Plan ${suffix}`)) throw new Error('Company A sees company B PM plan');
// Dashboards differ per company.
const dashA = (await request('/api/dashboard', { headers: companyHeaders(companyA) })).stats;
const dashB = (await request('/api/dashboard', { headers: companyHeaders(companyB) })).stats;
if (dashB.asset_count !== 1) throw new Error(`Company B dashboard should count exactly its 1 asset (got ${dashB.asset_count})`);
if (dashA.asset_count <= dashB.asset_count) throw new Error('Company A (seeded data) should have more assets than the new company B');
// Dispose company B: it leaves the active list but its data is preserved; then restore it.
await request(`/api/entities/${companyB}/dispose`, { method: 'POST', headers: auth, body: JSON.stringify({ reason: 'smoke test' }) });
const activeAfter = (await request('/api/entities', { headers: auth })).entities;
if (activeAfter.some((e) => e.id === companyB)) throw new Error('Disposed company still appears in the active list');
const allAfter = (await request('/api/entities?includeDisposed=1', { headers: auth })).entities;
const disposedRow = allAfter.find((e) => e.id === companyB);
if (!disposedRow || disposedRow.status !== 'disposed') throw new Error('Disposed company missing from includeDisposed list');
await request(`/api/entities/${companyB}/restore`, { method: 'POST', headers: auth, body: JSON.stringify({}) });
const restoredAssets = (await request('/api/assets', { headers: companyHeaders(companyB) })).assets;
if (!restoredAssets.some((a) => a.id === assetB.id)) throw new Error('Company B asset was lost across dispose/restore');
// ---- Phase 2: reference data / contacts / templates are per-company ---------
// A new company auto-seeds the standard categories.
const seededCatsB = (await request('/api/asset-categories', { headers: companyHeaders(companyB) })).categories;
if (!seededCatsB.some((c) => c.name === 'Computer Equipment')) throw new Error('New company did not auto-seed reference categories');
await request('/api/asset-categories', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Category ${suffix}` }) });
const catsA = (await request('/api/asset-categories', { headers: companyHeaders(companyA) })).categories;
const catsB = (await request('/api/asset-categories', { headers: companyHeaders(companyB) })).categories;
if (!catsB.some((c) => c.name === `B Category ${suffix}`)) throw new Error('Company B category missing');
if (catsA.some((c) => c.name === `B Category ${suffix}`)) throw new Error('Company A sees company B category');
// Contacts are per-company.
await request('/api/contacts', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ type: 'vendor', organization: `B Vendor ${suffix}` }) });
const contactsA = (await request('/api/contacts', { headers: companyHeaders(companyA) })).contacts;
const contactsB = (await request('/api/contacts', { headers: companyHeaders(companyB) })).contacts;
if (!contactsB.some((c) => c.organization === `B Vendor ${suffix}`)) throw new Error('Company B contact missing');
if (contactsA.some((c) => c.organization === `B Vendor ${suffix}`)) throw new Error('Company A sees company B contact');
// Templates are per-company.
await request('/api/templates', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Template ${suffix}`, defaults: {}, custom_fields: [] }) });
const tplA = (await request('/api/templates', { headers: companyHeaders(companyA) })).templates;
const tplB = (await request('/api/templates', { headers: companyHeaders(companyB) })).templates;
if (!tplB.some((t) => t.name === `B Template ${suffix}`)) throw new Error('Company B template missing');
if (tplA.some((t) => t.name === `B Template ${suffix}`)) throw new Error('Company A sees company B template');
// ---- Per-company user access control ---------------------------------------
const limitedEmail = `limited-${suffix}@example.com`;
const limitedPassword = 'Acc3ssControl!2026X';
await request('/api/users', {
method: 'POST', headers: companyHeaders(companyA),
body: JSON.stringify({ name: 'Limited Person', email: limitedEmail, password: limitedPassword, role: 'viewer', company_ids: [companyA] })
});
const limitedLogin = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: limitedEmail, password: limitedPassword }) });
const limitedAuth = { Authorization: `Bearer ${limitedLogin.token}` };
// The switcher only lists company A for this user.
const limitedCompanies = (await request('/api/entities', { headers: limitedAuth })).entities;
if (limitedCompanies.length !== 1 || limitedCompanies[0].id !== companyA) throw new Error('Limited user can see companies beyond their assignment');
// Forging X-Company-Id to company B falls back to company A (no leak of B's data).
const limitedBView = (await request('/api/assets', { headers: { ...limitedAuth, 'X-Company-Id': String(companyB) } })).assets;
if (limitedBView.some((a) => a.id === assetB.id)) throw new Error('Limited user accessed another company by forging the header');
if (!limitedBView.some((a) => a.id === assetAonly.id)) throw new Error('Limited user did not fall back to their permitted company');
// Admins still see every active company.
const adminCompanies = (await request('/api/entities', { headers: auth })).entities;
if (!adminCompanies.some((e) => e.id === companyA) || !adminCompanies.some((e) => e.id === companyB)) {
throw new Error('Admin should see all active companies');
}
// ---- Parts + assignments are per-company -----------------------------------
// The same part number is allowed in different companies (per-company uniqueness).
const partB = (await request('/api/parts', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ part_number: `PN-${suffix}`, name: 'B Part' }) })).part;
const partA = (await request('/api/parts', { method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ part_number: `PN-${suffix}`, name: 'A Part' }) })).part;
if (!partA || !partB || partA.id === partB.id) throw new Error('Per-company part-number uniqueness failed');
const partsA = (await request('/api/parts', { headers: companyHeaders(companyA) })).parts;
const partsB = (await request('/api/parts', { headers: companyHeaders(companyB) })).parts;
if (partsA.some((p) => p.id === partB.id)) throw new Error('Company A sees company B part');
if (!partsB.some((p) => p.id === partB.id)) throw new Error('Company B part missing');
const crossPart = await fetch(`${baseUrl}/api/parts/${partB.id}`, { headers: companyHeaders(companyA) });
if (crossPart.status !== 404) throw new Error('Cross-company part fetch should 404');
// Assignments scope to the assigned asset's company.
await request('/api/assignments', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ asset_id: assetB.id }) });
const assignsB = (await request('/api/assignments', { headers: companyHeaders(companyB) })).assignments;
const assignsA = (await request('/api/assignments', { headers: companyHeaders(companyA) })).assignments;
if (!assignsB.some((a) => a.asset_id === assetB.id)) throw new Error('Company B assignment missing');
if (assignsA.some((a) => a.asset_id === assetB.id)) throw new Error('Company A sees company B assignment');
// Assigning another company's asset is rejected.
const crossAssign = await fetch(`${baseUrl}/api/assignments`, {
method: 'POST', headers: { ...companyHeaders(companyA), 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: assetB.id })
});
if (crossAssign.status !== 404) throw new Error('Assigning an asset from another company should 404');
// The employee directory is per-company.
const empB = (await request('/api/employees', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: 'B Worker', employee_number: `EMP-${suffix}` }) })).employee;
if (!empB || empB.entity_id !== companyB) throw new Error('New employee was not scoped to the active company');
const empsA = (await request('/api/employees', { headers: companyHeaders(companyA) })).employees;
const empsB = (await request('/api/employees', { headers: companyHeaders(companyB) })).employees;
if (empsA.some((e) => e.id === empB.id)) throw new Error('Company A sees company B employee');
if (!empsB.some((e) => e.id === empB.id)) throw new Error('Company B employee missing');
// Company B's employee cannot be attached to a company A assignment (resolves to no employee).
const crossEmpAssign = (await request('/api/assignments', {
method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ asset_id: assetAonly.id, employee_id: empB.id })
})).assignment;
if (crossEmpAssign.employee_id) throw new Error('A company A assignment accepted a company B employee');
// ---- GL journal entries: CRUD + audit + import (conflict/merge) + export ----
const glEntry = (await request('/api/books/GAAP/gl-entries', {
method: 'POST', headers: companyHeaders(companyA),
body: JSON.stringify({ entry_date: '2026-03-01', account: '1500', account_type: 'Asset', description: 'Equipment', reference: 'INV-1', debit: 1000, credit: 0, external_id: `GL-${suffix}-100` })
})).entry;
if (!glEntry || glEntry.entity_id !== companyA) throw new Error('GL entry was not created/scoped');
const glListed = (await request('/api/books/GAAP/gl-entries?year=2026', { headers: companyHeaders(companyA) }));
if (!glListed.entries.some((e) => e.id === glEntry.id) || glListed.summary.debit < 1000) throw new Error('GL entry did not list with totals');
// Update it (full before/after must be audited).
await request(`/api/books/GAAP/gl-entries/${glEntry.id}`, {
method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ debit: 1200, description: 'Equipment (revised)' })
});
const glAudit = await request('/api/audit-logs?entity_type=gl_entry', { headers: auth });
if (glAudit.total < 2) throw new Error('GL changes were not audited');
const updateLog = glAudit.rows.find((r) => r.action === 'update' && r.entity_id === String(glEntry.id));
if (!updateLog || !updateLog.before_json || !updateLog.after_json) throw new Error('GL update did not capture full before/after');
// Import preview: one row matches the existing entry's external id (update), one is brand new.
const csv = `Date,Account,Account Type,Description,Reference,Debit,Credit,External Id\n` +
`2026-03-01,1500,Asset,Equipment changed,INV-1,2000,0,GL-${suffix}-100\n` +
`2026-03-05,6400,Expense,Depreciation,INV-2,0,250,GL-${suffix}-200\n`;
const previewForm = new FormData();
previewForm.append('file', new Blob([csv], { type: 'text/csv' }), 'gl.csv');
const glPreview = await (await fetch(`${baseUrl}/api/books/GAAP/gl-entries/import/preview`, { method: 'POST', headers: companyHeaders(companyA), body: previewForm })).json();
if (glPreview.summary.new !== 1 || glPreview.summary.update !== 1) {
throw new Error(`Import preview misclassified rows: ${JSON.stringify(glPreview.summary)}`);
}
// Commit with merge: insert the new row, update the matched one.
const glCommit = await request('/api/books/GAAP/gl-entries/import/commit', {
method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ rows: glPreview.rows, strategy: 'merge' })
});
if (glCommit.inserted !== 1 || glCommit.updated !== 1) throw new Error(`Import commit applied wrong counts: ${JSON.stringify(glCommit)}`);
// Export CSV + Excel.
const glCsv = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/export?year=2026&format=csv`, { headers: companyHeaders(companyA) });
if (!(glCsv.headers.get('content-type') || '').includes('text/csv')) throw new Error('GL CSV export was not text/csv');
if (!(await glCsv.text()).startsWith('Date,Account')) throw new Error('GL CSV header was malformed');
const glXlsx = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/export?year=2026&format=xlsx`, { headers: companyHeaders(companyA) });
if (!(glXlsx.headers.get('content-type') || '').includes('spreadsheet')) throw new Error('GL Excel export had the wrong content-type');
// Per-entry change history (create + update events with before/after).
const glHistory = (await request(`/api/books/GAAP/gl-entries/${glEntry.id}/history`, { headers: companyHeaders(companyA) })).history;
if (glHistory.length < 2 || !glHistory.some((h) => h.action === 'update' && h.before && h.after)) {
throw new Error('GL entry history did not return the create + update timeline');
}
// History is company-scoped (company B can't read company A's entry history).
const crossHistory = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/${glEntry.id}/history`, { headers: companyHeaders(companyB) });
if (crossHistory.status !== 404) throw new Error('Cross-company GL history should 404');
// Isolation: company B's GAAP book sees none of company A's entries.
const glB = (await request('/api/books/GAAP/gl-entries', { headers: companyHeaders(companyB) })).entries;
if (glB.some((e) => e.id === glEntry.id)) throw new Error('Company B sees company A GL entries');
// ---- GL chart of accounts + posting defaults -------------------------------
const seededChart = await request('/api/gl-accounts', { headers: companyHeaders(companyA) });
if (seededChart.accounts.length < 5 || !seededChart.defaults.asset_account) throw new Error('Chart of accounts / posting defaults were not seeded');
const newAcct = (await request('/api/gl-accounts', {
method: 'POST', headers: companyHeaders(companyA),
body: JSON.stringify({ code: `1501-${suffix}`, name: 'Equipment — Co A', type: 'asset', normal_balance: 'debit' })
})).account;
if (!newAcct || newAcct.entity_id !== companyA) throw new Error('GL account was not created/scoped');
await request(`/api/gl-accounts/${newAcct.id}`, { method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ name: 'Equipment — revised' }) });
// Point the asset-cost posting default at the new account and confirm the ledger posts to it.
await request('/api/gl-account-defaults', { method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ asset_account: `1501-${suffix}` }) });
const ledgerA = (await request('/api/books/GAAP/ledger?year=2026', { headers: companyHeaders(companyA) })).ledger;
if (!ledgerA.accounts.some((a) => a.account === `1501-${suffix}` && a.type === 'Asset cost')) {
throw new Error('Ledger did not use the configured default asset account');
}
// GL account + defaults changes are audited.
const acctAudit = await request('/api/audit-logs?entity_type=gl_account', { headers: auth });
if (acctAudit.total < 2) throw new Error('GL account changes were not audited');
if ((await request('/api/audit-logs?entity_type=gl_account_defaults', { headers: auth })).total < 1) {
throw new Error('GL account-default change was not audited');
}
// Isolation: company B has its own seeded chart, not company A's custom account.
const acctsB = (await request('/api/gl-accounts', { headers: companyHeaders(companyB) })).accounts;
if (!acctsB.length) throw new Error('Company B chart of accounts was not seeded');
if (acctsB.some((a) => a.code === `1501-${suffix}`)) throw new Error('Company B sees company A GL account');
// ---- Application (Winston) event logs --------------------------------------
const appLog = await request('/api/app-logs', { headers: auth });
if (!appLog.logs.length) throw new Error('Application logs were empty');
if (!appLog.categories.includes('http')) throw new Error('Application logs did not capture HTTP events');
const logSample = appLog.logs[0];
if (!logSample.timestamp || !logSample.level || !logSample.category) throw new Error('Application log entry was missing fields');
const httpLogs = await request('/api/app-logs?category=http', { headers: auth });
if (!httpLogs.logs.length || httpLogs.logs.some((l) => l.category !== 'http')) throw new Error('Application log category filter failed');
// Requires the admin.logs capability — a viewer-role user is refused.
const logForbidden = await fetch(`${baseUrl}/api/app-logs`, { headers: limitedAuth });
if (logForbidden.status !== 403) throw new Error('Application logs should require the admin.logs capability');
console.log('Smoke test passed');
}
main()
.catch((error) => {
console.error(error.message);
process.exitCode = 1;
})
.finally(() => {
child.kill('SIGTERM');
for (const suffix of ['', '-wal', '-shm']) {
try {
fs.rmSync(`${dbPath}${suffix}`, { force: true });
} catch {
// best-effort cleanup of the temp database
}
}
});