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 ', 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'); 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 } } });