2107 lines
138 KiB
JavaScript
2107 lines
138 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(), `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%×(600000−200000)=200000 + SL ((600000−200000−200000)/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%×(600000−500000)=50000 + SL ((600000−500000−50000)/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');
|
||
|
||
// ---- Period close (month-end / year-end) -----------------------------------
|
||
// Run the whole close in company A explicitly (bare `auth` resolves to the admin's first company,
|
||
// which isn't deterministically company A). Seed a depreciating GAAP asset so depreciation is non-zero.
|
||
const aHeaders = companyHeaders(companyA);
|
||
await request('/api/assets', {
|
||
method: 'POST', headers: aHeaders,
|
||
body: JSON.stringify({
|
||
asset_id: `CLZ-${suffix}`, description: 'Close-test machine', category: 'Computer Equipment',
|
||
acquisition_cost: 12000, acquired_date: '2026-01-10', in_service_date: '2026-01-10', useful_life_months: 60,
|
||
books: [{ book_type: 'GAAP', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, cost: 12000 }]
|
||
})
|
||
});
|
||
|
||
const startClose = await request('/api/close-periods/start', {
|
||
method: 'POST', headers: aHeaders,
|
||
body: JSON.stringify({ book: 'GAAP', period_type: 'month', fiscal_year: 2026, period_month: 3 })
|
||
});
|
||
const closeId = startClose.period.id;
|
||
if (startClose.period.status !== 'in_progress') throw new Error('Close period did not start');
|
||
|
||
const postedSource = `close:${closeId}`;
|
||
const closeEntries = async () => (await request('/api/books/GAAP/gl-entries?year=2026', { headers: aHeaders })).entries.filter((e) => e.source === postedSource);
|
||
|
||
// Post depreciation → balanced JE (Dr expense = Cr accumulated), tagged close:<id>.
|
||
const dep = (await request(`/api/close-periods/${closeId}/post-depreciation`, { method: 'POST', headers: aHeaders })).summary;
|
||
if (!(dep.debit > 0) || dep.debit !== dep.credit) throw new Error(`Depreciation JE not balanced: ${JSON.stringify(dep)}`);
|
||
const firstPost = await closeEntries();
|
||
if (!firstPost.length) throw new Error('Close did not post GL entries');
|
||
|
||
// Idempotent re-post: same entry count, not doubled.
|
||
await request(`/api/close-periods/${closeId}/post-depreciation`, { method: 'POST', headers: aHeaders });
|
||
if ((await closeEntries()).length !== firstPost.length) throw new Error('Depreciation re-post was not idempotent');
|
||
|
||
// Reconcile, then mark the reconcile step done (acknowledging any opening variance).
|
||
const recon = (await request(`/api/close-periods/${closeId}/reconcile`, { method: 'POST', headers: aHeaders })).reconciliation;
|
||
if (!recon.lines || recon.lines.length !== 3) throw new Error('Reconciliation result malformed');
|
||
await request(`/api/close-periods/${closeId}/steps/reconcile`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ done: true, acknowledged: true, notes: 'opening balance' }) });
|
||
|
||
// Finalize → period closed.
|
||
const finalized = (await request(`/api/close-periods/${closeId}/finalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ acknowledge_variance: true }) })).period;
|
||
if (finalized.status !== 'closed') throw new Error('Finalize did not close the period');
|
||
|
||
// Lock: a backdated GL entry into the closed month is rejected (409).
|
||
const lockedPost = await fetch(`${baseUrl}/api/books/GAAP/gl-entries`, {
|
||
method: 'POST', headers: { ...aHeaders, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ entry_date: '2026-03-15', account: '6400', debit: 10, credit: 0 })
|
||
});
|
||
if (lockedPost.status !== 409) throw new Error(`Backdated GL post into a closed period should be 409, got ${lockedPost.status}`);
|
||
|
||
// Reopen → close JEs reversed, status in_progress, and the backdated write now succeeds.
|
||
const reopened = (await request(`/api/close-periods/${closeId}/reopen`, { method: 'POST', headers: aHeaders })).period;
|
||
if (reopened.status !== 'in_progress') throw new Error('Reopen did not reset status');
|
||
if ((await closeEntries()).length !== 0) throw new Error('Reopen did not reverse the close postings');
|
||
await request('/api/books/GAAP/gl-entries', { method: 'POST', headers: aHeaders, body: JSON.stringify({ entry_date: '2026-03-15', account: '6400', debit: 10, credit: 0 }) });
|
||
|
||
// Every close action is audited.
|
||
const closeAudit = await request('/api/audit-logs?entity_type=close_period', { headers: auth });
|
||
for (const action of ['start', 'post_depreciation', 'finalize', 'reopen']) {
|
||
if (!closeAudit.rows.some((r) => r.action === action)) throw new Error(`Close action not audited: ${action}`);
|
||
}
|
||
|
||
// Isolation: company B cannot read company A's close period.
|
||
const crossClose = await fetch(`${baseUrl}/api/close-periods/${closeId}`, { headers: companyHeaders(companyB) });
|
||
if (crossClose.status !== 404) throw new Error('Cross-company close period should 404');
|
||
|
||
// Capitalize WIP: a Construction-in-progress asset moves into service + posts a JE.
|
||
const cip = (await request('/api/assets', {
|
||
method: 'POST', headers: aHeaders,
|
||
body: JSON.stringify({ asset_id: `WIP-${suffix}`, description: 'Build in progress', category: 'Computer Equipment', acquisition_cost: 5000, status: 'cip', acquired_date: '2026-02-01' })
|
||
})).asset;
|
||
const capped = await request(`/api/close-periods/${closeId}/wip/${cip.id}/capitalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ in_service_date: '2026-06-01' }) });
|
||
if (capped.asset.status !== 'in_service') throw new Error('WIP capitalization did not place the asset in service');
|
||
|
||
// Year close: finalize runs the roll-forward to Retained Earnings.
|
||
const yearClose = (await request('/api/close-periods/start', { method: 'POST', headers: aHeaders, body: JSON.stringify({ book: 'GAAP', period_type: 'year', fiscal_year: 2026 }) })).period;
|
||
await request(`/api/close-periods/${yearClose.id}/post-depreciation`, { method: 'POST', headers: aHeaders });
|
||
await request(`/api/close-periods/${yearClose.id}/reconcile`, { method: 'POST', headers: aHeaders });
|
||
for (const key of ['final_month_close', 'reconcile', 'physical_inventory']) {
|
||
await request(`/api/close-periods/${yearClose.id}/steps/${key}`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ done: true, acknowledged: true }) });
|
||
}
|
||
await request(`/api/close-periods/${yearClose.id}/finalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ acknowledge_variance: true }) });
|
||
const reEntries = (await request('/api/books/GAAP/gl-entries?year=2026', { headers: aHeaders })).entries
|
||
.filter((e) => e.source === `close:${yearClose.id}` && e.account_type === 'Retained earnings');
|
||
if (!reEntries.length) throw new Error('Year-end roll-forward did not post a Retained Earnings entry');
|
||
|
||
// ---- Dashboard designer: per-user layout + depreciation aggregator ----------
|
||
const freshLayout = await request('/api/dashboard/layout', { headers: auth });
|
||
if (freshLayout.layout !== null && !Array.isArray(freshLayout.layout)) throw new Error('Dashboard layout endpoint returned an unexpected shape');
|
||
const myLayout = [
|
||
{ i: 'portfolio-stats-1', type: 'portfolio-stats', x: 0, y: 0, w: 12, h: 3 },
|
||
{ i: 'asset-scanner-1', type: 'asset-scanner', x: 0, y: 3, w: 4, h: 5 }
|
||
];
|
||
await request('/api/dashboard/layout', { method: 'PUT', headers: auth, body: JSON.stringify({ layout: myLayout }) });
|
||
const savedLayout = (await request('/api/dashboard/layout', { headers: auth })).layout;
|
||
if (!Array.isArray(savedLayout) || savedLayout.length !== 2 || savedLayout[0].type !== 'portfolio-stats') throw new Error('Dashboard layout did not persist for the user');
|
||
// Per-user isolation: a different user has an independent (empty) layout.
|
||
const otherLayout = (await request('/api/dashboard/layout', { headers: limitedAuth })).layout;
|
||
if (Array.isArray(otherLayout) && otherLayout.length) throw new Error('Dashboard layout leaked across users');
|
||
|
||
// Depreciation aggregator (company A has a depreciating GAAP asset from the close block).
|
||
const deprAgg = await request('/api/dashboard/depreciation?year=2026', { headers: aHeaders });
|
||
if (!Array.isArray(deprAgg.byBook) || !deprAgg.byBook.some((b) => b.book === 'GAAP')) throw new Error('Dashboard depreciation aggregator missing byBook/GAAP');
|
||
if (!Array.isArray(deprAgg.byCategory)) throw new Error('Dashboard depreciation aggregator missing byCategory');
|
||
|
||
// ---- MACRS mid-quarter 40% auto-test (IRS Pub 946) --------------------------
|
||
// Q4-heavy portfolio (80% of basis placed in service in Q4) → mid-quarter convention is mandatory and
|
||
// must be applied automatically: the Q4 asset's first-year MACRS depreciation should be the mid-quarter
|
||
// amount (8000 × 0.40 × 0.125 = 400), not the half-year amount (8000 × 0.40 × 0.5 = 1600).
|
||
const macrsBook = (cost) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost });
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MQ1-${suffix}`, description: 'Mid-quarter Q1', category: 'Computer Equipment', acquisition_cost: 2000, acquired_date: '2029-02-15', in_service_date: '2029-02-15', useful_life_months: 60, books: [macrsBook(2000)] }) });
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MQ4-${suffix}`, description: 'Mid-quarter Q4', category: 'Computer Equipment', acquisition_cost: 8000, acquired_date: '2029-11-15', in_service_date: '2029-11-15', useful_life_months: 60, books: [macrsBook(8000)] }) });
|
||
const mqReport = (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year: 2029 } }) })).report;
|
||
const mqRow = mqReport.rows.find((r) => r.asset_id === `MQ4-${suffix}`);
|
||
if (!mqRow) throw new Error('Mid-quarter test asset missing from the depreciation report');
|
||
if (Math.abs(mqRow.depreciation - 400) > 1) throw new Error(`Mid-quarter convention not auto-applied: expected ~400 (mid-quarter), got ${mqRow.depreciation}`);
|
||
|
||
// Verify the §179 limits were updated to the IRS Pub 946 (2026) figures.
|
||
const s179 = (await request('/api/section-179-limits', { headers: auth })).limits;
|
||
const y2026 = s179.find((l) => l.tax_year === 2026);
|
||
if (!y2026 || Number(y2026.base_limit) !== 2560000 || Number(y2026.phaseout_threshold) !== 4090000) {
|
||
throw new Error(`2026 §179 limit should be 2,560,000 / 4,090,000, got ${JSON.stringify(y2026)}`);
|
||
}
|
||
|
||
// ---- §280F passenger-auto cap + listed-property >50% ADS test (IRS Pub 946) ---
|
||
const macrsBook5 = (cost) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost });
|
||
// $90k luxury auto, 100% business, 2024 → year-1 MACRS would be 18,000 but §280F caps it to 12,400.
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `AUTO-${suffix}`, description: 'Luxury auto', category: 'Computer Equipment', acquisition_cost: 90000, acquired_date: '2024-06-01', in_service_date: '2024-06-01', useful_life_months: 60, business_use_percent: 100, passenger_auto: true, books: [macrsBook5(90000)] }) });
|
||
// Listed property at 40% business use → forced ADS straight-line (basis 10k×40% / 5yr × half-year = 400).
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `LIST-${suffix}`, description: 'Listed low-use', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-06-01', in_service_date: '2024-06-01', useful_life_months: 60, business_use_percent: 40, listed_property: true, books: [macrsBook5(10000)] }) });
|
||
const depr2024 = (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year: 2024 } }) })).report;
|
||
const autoRow = depr2024.rows.find((r) => r.asset_id === `AUTO-${suffix}`);
|
||
if (!autoRow || Math.abs(autoRow.depreciation - 12400) > 1) throw new Error(`§280F auto cap not applied: expected 12,400, got ${autoRow && autoRow.depreciation}`);
|
||
const listRow = depr2024.rows.find((r) => r.asset_id === `LIST-${suffix}`);
|
||
if (!listRow || Math.abs(listRow.depreciation - 400) > 1) throw new Error(`Listed ≤50% ADS not applied: expected 400, got ${listRow && listRow.depreciation}`);
|
||
if (!(await request('/api/section-280f-limits', { headers: auth })).limits.some((l) => l.pis_year === 2024)) throw new Error('§280F limits not seeded');
|
||
|
||
// ---- §179 taxable-income limitation + carryover ----------------------------
|
||
const s179Book = (amt) => ({ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_7', useful_life_months: 84, section_179_amount: amt, cost: amt });
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `S179A-${suffix}`, description: '179 A', category: 'Computer Equipment', acquisition_cost: 1500000, acquired_date: '2031-03-01', in_service_date: '2031-03-01', useful_life_months: 84, books: [s179Book(1500000)] }) });
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `S179B-${suffix}`, description: '179 B', category: 'Computer Equipment', acquisition_cost: 1500000, acquired_date: '2031-04-01', in_service_date: '2031-04-01', useful_life_months: 84, books: [s179Book(1500000)] }) });
|
||
// Elected 3,000,000; 2031 dollar limit 2,560,000; taxable income 1,000,000 → allowed 1,000,000, carry 2,000,000.
|
||
const limSaved = (await request('/api/section-179/limitation', { method: 'PUT', headers: aHeaders, body: JSON.stringify({ book: 'FEDERAL', year: 2031, taxable_income: 1000000 }) })).summary;
|
||
if (limSaved.elected !== 3000000) throw new Error(`§179 elected wrong: ${limSaved.elected}`);
|
||
if (limSaved.dollar_allowed !== 2560000) throw new Error(`§179 dollar limit wrong: ${limSaved.dollar_allowed}`);
|
||
if (limSaved.allowed_deduction !== 1000000) throw new Error(`§179 income limitation wrong: ${limSaved.allowed_deduction}`);
|
||
if (limSaved.carryover_to_next !== 2000000) throw new Error(`§179 carryover wrong: ${limSaved.carryover_to_next}`);
|
||
const lim2032 = (await request('/api/section-179/limitation?book=FEDERAL&year=2032', { headers: aHeaders })).summary;
|
||
if (lim2032.prior_carryover !== 2000000) throw new Error(`§179 carryover did not chain to next year: ${lim2032.prior_carryover}`);
|
||
|
||
// ---- Depreciation recapture: §1245 on disposal + §280F conversion (Form 4797) ---
|
||
// Fully-depreciated equipment (cost 10k, placed 2018) sold for 12k in 2026 → gain 12k: §1245 ordinary
|
||
// recapture = depreciation taken (10k), the remaining 2k is §1231 capital gain.
|
||
const eq = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `EQ-${suffix}`, description: 'Equipment', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2018-01-01', in_service_date: '2018-01-01', useful_life_months: 60, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) })).asset;
|
||
const recapDisp = (await request(`/api/assets/${eq.id}/disposal/preview`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ book_type: 'FEDERAL', disposal_date: '2026-08-01', disposal_price: 12000 }) })).preview;
|
||
if (recapDisp.recapture_section !== '1245') throw new Error(`Recapture section wrong: ${disp.recapture_section}`);
|
||
if (recapDisp.ordinary_recapture !== 10000) throw new Error(`§1245 ordinary recapture wrong: expected 10,000, got ${recapDisp.ordinary_recapture}`);
|
||
if (recapDisp.section_1231_gain !== 2000) throw new Error(`§1231 gain wrong: expected 2,000, got ${recapDisp.section_1231_gain}`);
|
||
|
||
// §280F conversion recapture: listed PC at 100% use (2024) converting to ≤50% in 2026 → excess of
|
||
// accelerated-over-ADS through 2025 = 5,200 − 3,000 = 2,200 ordinary income.
|
||
const pc = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `PC-${suffix}`, description: 'Listed PC', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-01-01', in_service_date: '2024-01-01', useful_life_months: 60, business_use_percent: 100, listed_property: true, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) })).asset;
|
||
const rec280 = (await request(`/api/assets/${pc.id}/section-280f-recapture?book=FEDERAL&year=2026`, { headers: aHeaders })).recapture;
|
||
if (!rec280.applies || rec280.recapture !== 2200) throw new Error(`§280F recapture wrong: ${JSON.stringify(rec280)}`);
|
||
|
||
// Farm-property recapture (IRS Pub 225): §1252 soil/water (§175) + §1255 cost-sharing (§126). Farmland
|
||
// held 7 years with $20k §175 and $10k §126, sold at a $50k gain → §1252 = 60% × 20k = 12k; §1255 = 10k.
|
||
const farm = (await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `FARM-${suffix}`, description: 'Farmland', category: 'Computer Equipment', acquisition_cost: 100000, land_value: 100000, in_service_date: '2019-06-01', acquired_date: '2019-06-01', useful_life_months: 0, soil_water_deductions: 20000, cost_sharing_excluded: 10000, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'straight_line', useful_life_months: 0, cost: 100000 }] }) })).asset;
|
||
const farmDisp = (await request(`/api/assets/${farm.id}/disposal/preview`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ book_type: 'FEDERAL', disposal_date: '2026-07-01', disposal_price: 150000 }) })).preview;
|
||
if (farmDisp.section_1252_recapture !== 12000) throw new Error(`§1252 recapture wrong: expected 12,000, got ${farmDisp.section_1252_recapture}`);
|
||
if (farmDisp.section_1255_recapture !== 10000) throw new Error(`§1255 recapture wrong: expected 10,000, got ${farmDisp.section_1255_recapture}`);
|
||
if (farmDisp.section_1231_gain !== 28000) throw new Error(`Farm §1231 gain wrong: expected 28,000, got ${farmDisp.section_1231_gain}`);
|
||
|
||
// ---- Form 4562 filing worksheet (self-contained on a clean year) -----------
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `F62M-${suffix}`, description: 'Machine', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2035-04-01', in_service_date: '2035-04-01', useful_life_months: 60, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) });
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `F62A-${suffix}`, description: 'Auto', category: 'Computer Equipment', acquisition_cost: 90000, acquired_date: '2035-06-01', in_service_date: '2035-06-01', useful_life_months: 60, passenger_auto: true, business_use_percent: 100, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 90000 }] }) });
|
||
const f4562 = (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'form_4562', options: { year: 2035 } }) })).report;
|
||
if (!f4562.rows.some((r) => r.line === '12') || !f4562.rows.some((r) => r.line === '22')) throw new Error('Form 4562 worksheet missing §179 (line 12) / total (line 22)');
|
||
if (!f4562.meta || !Array.isArray(f4562.meta.sections) || f4562.meta.sections.length !== 2) throw new Error('Form 4562 missing Part III / Part V sections');
|
||
const f4562Line19 = f4562.rows.find((r) => r.line === '19');
|
||
if (!f4562Line19 || Math.abs(f4562Line19.amount - 2000) > 1) throw new Error(`Form 4562 line 19 (current-year MACRS) wrong: expected 2,000, got ${f4562Line19 && f4562Line19.amount}`);
|
||
if (!f4562.meta.sections[1].rows.some((r) => r.type === 'Auto')) throw new Error('Form 4562 Part V should list the passenger auto');
|
||
|
||
// ---- Literal MACRS tables (MT200), true ADS recovery period, short tax year ---
|
||
const deprRow = async (assetId, year) => (await request('/api/reports/run', { method: 'POST', headers: aHeaders, body: JSON.stringify({ type: 'depreciation_expense', options: { book: 'FEDERAL', year } }) })).report.rows.find((r) => r.asset_id === assetId);
|
||
// #1 MT200 uses the literal Pub 946 Appendix-A percentages (5-year property year-1 = 20% = 2,000).
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `MT-${suffix}`, description: 'MT200', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2024-05-01', in_service_date: '2024-05-01', useful_life_months: 60, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'MT200', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) });
|
||
const mtRow = await deprRow(`MT-${suffix}`, 2024);
|
||
if (!mtRow || Math.abs(mtRow.depreciation - 2000) > 1) throw new Error(`MT200 literal table year-1 wrong: expected 2,000, got ${mtRow && mtRow.depreciation}`);
|
||
|
||
// #3 Listed property ≤50% business use uses the ADS recovery period from its asset class (GDS 7 / ADS 10):
|
||
// basis 10k×40% = 4k, ADS 10-year straight-line, half-year → 200 (not the 7-year GDS life).
|
||
await request('/api/asset-classes', { method: 'POST', headers: auth, body: JSON.stringify({ class_number: `88.${suffix}`, description: 'ADS test', gds: 7, ads: 10 }) });
|
||
await request('/api/asset-categories', { method: 'POST', headers: aHeaders, body: JSON.stringify({ name: `ADSCat-${suffix}`, asset_class_number: `88.${suffix}` }) });
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `ADS-${suffix}`, description: 'ADS', category: `ADSCat-${suffix}`, acquisition_cost: 10000, acquired_date: '2024-05-01', in_service_date: '2024-05-01', useful_life_months: 84, business_use_percent: 40, listed_property: true, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_7', convention: 'half_year', useful_life_months: 84, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) });
|
||
const adsRow = await deprRow(`ADS-${suffix}`, 2024);
|
||
if (!adsRow || Math.abs(adsRow.depreciation - 200) > 1) throw new Error(`ADS recovery period not applied: expected 200 (10-yr ADS), got ${adsRow && adsRow.depreciation}`);
|
||
|
||
// #2 Short tax year prorates that year's MACRS depreciation by months ÷ 12 (2,000 → 1,000 for 6 months).
|
||
await request('/api/assets', { method: 'POST', headers: aHeaders, body: JSON.stringify({ asset_id: `STY-${suffix}`, description: 'STY', category: 'Computer Equipment', acquisition_cost: 10000, acquired_date: '2033-01-01', in_service_date: '2033-01-01', useful_life_months: 60, books: [{ book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, bonus_percent: 0, section_179_amount: 0, cost: 10000 }] }) });
|
||
const styBaseline = await deprRow(`STY-${suffix}`, 2033);
|
||
if (!styBaseline || Math.abs(styBaseline.depreciation - 2000) > 1) throw new Error(`Short-year baseline wrong: ${styBaseline && styBaseline.depreciation}`);
|
||
await request('/api/short-tax-years', { method: 'POST', headers: aHeaders, body: JSON.stringify({ tax_year: 2033, months: 6 }) });
|
||
const styShort = await deprRow(`STY-${suffix}`, 2033);
|
||
if (!styShort || Math.abs(styShort.depreciation - 1000) > 1) throw new Error(`Short tax year not prorated: expected 1,000, got ${styShort && styShort.depreciation}`);
|
||
|
||
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
|
||
}
|
||
}
|
||
});
|