1247 lines
70 KiB
JavaScript
1247 lines
70 KiB
JavaScript
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(), `mixedassets-smoke-${Date.now()}.sqlite`);
|
||
const child = spawn(process.execPath, ['server/index.js'], {
|
||
cwd: process.cwd(),
|
||
env: { ...process.env, PORT: String(port), MIXEDASSETS_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@mixedassets.local',
|
||
password: process.env.MIXEDASSETS_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 !== 'mixedAssetsDark') {
|
||
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: 'mixedAssetsDark' } })
|
||
});
|
||
if (updatedProfile.preferences.theme !== 'mixedAssetsDark') {
|
||
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 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: 'MixedAssets <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');
|
||
}
|
||
|
||
// 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}`);
|
||
|
||
// 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');
|
||
|
||
// 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-mixedassets-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));
|
||
}
|
||
|
||
// 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_mixedassets', 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));
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
});
|