CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
* feat: move login credentials to a DB-backed users table with an admin management page Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds admin-only /api/users CRUD routes and a "Brugere" admin page in the frontend for managing logins without redeploying. Removes the unused, unmounted duplicate login route in src/routes/auth.js. * docs: add architecture codemaps with diagrams for the whole system Adds codemaps/architecture.md, backend.md, frontend.md, and data.md — Mermaid-diagrammed design documentation verified against the live codebase and database rather than assumed from CLAUDE.md. Covers the unified-server.js request flow (mounted routers + ~183 inline routes), 68 backend services grouped by domain, the frontend's state-driven view-switch (no React Router in practice despite BrowserRouter being present), and the full 122-table DB schema with the auth_accounts vs unrelated users table naming trap flagged explicitly. Links added from the root README. Co-Authored-By: Claude Sonnet 5 <[email protected]> * feat: ship canonical roof quote workflow * fix: keep migration dry-run idempotent * [verified] feat: complete Smart Pakker management * [verified] fix: ignore blank task dependencies * [verified] fix: align package duplication with schema * [verified] fix: enforce Discord status limits * [verified] fix: link Smart Pakke materials safely * [verified] fix: harden material link review * [verified] feat: improve material matching * fix: scope pitch validation to roof packages * fix: support canonical snapshots on production schema * [verified] fix: hide internal package metadata from PDF * [verified] feat: deliver sales-ready customer PDF * [verified] feat: ship sales-ready PDF with AI overview * [verified] fix: authenticate project list requests * [verified] fix: refresh project-list authentication * [verified] fix: open existing project details * [verified] fix: keep roof components searchable in builder * [verified] fix: expose all Smart Package categories * [verified] fix: authenticate project creation * [verified] feat: make Smart Pakker the universal project flow * [verified] feat: preview Smart Package contents * [verified] test: keep generic release isolated from downpipe work * feat: add first-class Smart Pakke rentals * [verified] feat: add gutter and downpipe smart packages * [verified] fix: prepare six-house gutter quote flow * [verified] fix: open generic quotes without roof geometry * [verified] fix: review generic quotes with authenticated APIs * [verified] fix: calculate generic Smart Package quotes * [verified] fix: return generic calculation breakdown * feat: checkpoint generic signed snapshot validation with red-green tests * feat: complete fail-closed generic quote approval and customer PDF flow * feat: use generic signed snapshot in final review * feat: redesign generic quote final review * fix: harden generic review summaries * feat: add auditable six-house package basis * [verified] feat: finish auditable Smart Pakke UI * [verified] fix: bind auditable quantity and price bases * [verified] fix: keep six-house basis across package versions * [verified] fix: complete smart package discovery management * [verified] fix: simplify composition and generic scope * [verified] test: keep explicit roof contracts fail closed * [verified] fix: harden generic quote snapshots * fix: make generic quote delivery customer safe * [verified] fix: secure package catalog reads * [verified] fix: close workspace provenance blockers * fix: harden customer document language boundary * [verified] fix: secure smart package internal reads * fix: version package child mutations atomically * feat: add generic customer quote text flow * [verified] fix: allow manual customer numbers * [verified] fix: expose optional roof geometry * [verified] fix: rebase hydrated packages after geometry edits * [verified] feat: add free editable site area map * [verified] fix: harden map recovery and geocoding gate * fix: bind map quantities to authoritative geometry * fix: release geocoder lock before dispatch * fix: separate roof and site geometry provenance * fix: revoke stale admin authorization * fix: migrate task geometry basis * fix: make backend CI dependency-complete * ci: seed isolated e2e login account * fix: allow clean database bootstrap * fix: skip indexes for optional tables * test: use canonical mansard geometry in e2e * [verified] fix(auth): enforce live operator boundary * fix: fail close Ordrestyring offer transport * fix(frontend): authenticate customer project requests * fix: align canonical roof type contract * [verified] fix: reconcile legacy package labor safely * [verified] fix: audit site geometry deletion * docs: add PR 31 reviewer guide * docs: synchronize Obsidian vault * docs: sync integrated reviewer guide to Obsidian * ci: seed isolated auth account explicitly * fix: close offer bootstrap and service readiness gaps * fix: authenticate protected package callers * fix: provision initial admin and disable generic send * [verified] fix: close final quote release blockers * [verified] fix: seed gutter packages before deployment --------- Co-authored-by: alexpolo1 <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]>
130 lines
7.0 KiB
JavaScript
130 lines
7.0 KiB
JavaScript
const { SiteGeometryService } = require('../src/services/siteGeometryService');
|
|
const polygon = { geometry: { type: 'Polygon', coordinates: [[[10,56],[10.001,56],[10.001,56.001],[10,56]]] } };
|
|
function database() {
|
|
let row; let events = []; let tail = Promise.resolve();
|
|
const execute = async (sql, args) => {
|
|
if (sql.startsWith('SELECT id')) return [[{ id: 7 }]];
|
|
if (sql.startsWith('SELECT')) return [row ? [row] : []];
|
|
if (sql.startsWith('INSERT INTO project_site_geometry_audit')) { events.push(args); return [{}]; }
|
|
if (sql.startsWith('INSERT INTO project_site_geometry ')) { row = { revision: args[1], geometry_json: args[2] }; return [{}]; }
|
|
throw new Error(sql);
|
|
};
|
|
return { events, query: async (...args) => (await execute(...args))[0], pool: { getConnection: async () => {
|
|
let release;
|
|
return { execute, beginTransaction: async () => { const prior = tail; tail = new Promise(r => { release=r; }); await prior; }, commit: async () => release(), rollback: async () => release(), release: () => {} };
|
|
} } };
|
|
}
|
|
test('serializes simultaneous initial writes, reads authoritative revision and audits operator', async () => {
|
|
const db = database(); const service = new SiteGeometryService(db);
|
|
expect(await service.get(7)).toBeNull();
|
|
const results = await Promise.allSettled(['alex','other'].map(operator => service.save(7, { ...polygon, expectedRevision: 0 }, operator)));
|
|
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1);
|
|
expect(results.find(r => r.status === 'rejected').reason).toMatchObject({ status: 409, code: 'GEOMETRY_REVISION_CONFLICT' });
|
|
const saved = await service.get(7);
|
|
expect(saved).toMatchObject({ revision: 1, audit: { createdBy: 'alex', updatedBy: 'alex' } });
|
|
const next = await service.save(7, { ...polygon, expectedRevision: 1 }, 'alex');
|
|
expect(next.revision).toBe(2);
|
|
expect(db.events).toHaveLength(2);
|
|
expect(next.signature).not.toBe(saved.signature);
|
|
});
|
|
test('requires explicit valid revision and operator', async () => {
|
|
const service = new SiteGeometryService(database());
|
|
await expect(service.save(7, polygon, 'alex')).rejects.toMatchObject({ status: 400 });
|
|
await expect(service.save(7, { ...polygon, expectedRevision: 0 }, '')).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
test('deletes with optimistic concurrency, retains a revision tombstone and appends audit evidence', async () => {
|
|
const db = database();
|
|
const service = new SiteGeometryService(db);
|
|
const saved = await service.save(7, { ...polygon, expectedRevision: 0 }, 'alex');
|
|
|
|
const deleted = await service.delete(7, { expectedRevision: saved.revision }, 'alex');
|
|
|
|
expect(deleted).toMatchObject({ geometry: null, revision: 2, deleted: true });
|
|
expect(await service.get(7)).toBeNull();
|
|
expect(await service.getState(7)).toMatchObject({ geometry: null, revision: 2 });
|
|
expect(db.events).toHaveLength(2);
|
|
expect(db.events[1]).toEqual(expect.arrayContaining([
|
|
7, 2, 'alex', 'deleted', expect.any(Date), saved.signature, expect.any(String), expect.any(String)
|
|
]));
|
|
const tombstone = JSON.parse(db.events[1][7]);
|
|
expect(tombstone).toMatchObject({ schema: 'site_geometry_tombstone_v1', revision: 2, deleted: true });
|
|
expect(tombstone.signature).toBe(db.events[1][6]);
|
|
});
|
|
|
|
test('refuses absent and stale deletion without appending evidence or changing geometry', async () => {
|
|
const db = database();
|
|
const service = new SiteGeometryService(db);
|
|
await expect(service.delete(7, { expectedRevision: 0 }, 'alex')).rejects.toMatchObject({
|
|
status: 404, code: 'SITE_GEOMETRY_NOT_FOUND'
|
|
});
|
|
expect(db.events).toHaveLength(0);
|
|
|
|
const saved = await service.save(7, { ...polygon, expectedRevision: 0 }, 'alex');
|
|
await expect(service.delete(7, { expectedRevision: 0 }, 'alex')).rejects.toMatchObject({
|
|
status: 409, code: 'GEOMETRY_REVISION_CONFLICT'
|
|
});
|
|
expect(await service.get(7)).toEqual(saved);
|
|
expect(db.events).toHaveLength(1);
|
|
});
|
|
|
|
test('requires deletion revision and operator and restores only from the tombstone revision', async () => {
|
|
const db = database();
|
|
const service = new SiteGeometryService(db);
|
|
const saved = await service.save(7, { ...polygon, expectedRevision: 0 }, 'alex');
|
|
await expect(service.delete(7, {}, 'alex')).rejects.toMatchObject({ status: 400 });
|
|
await expect(service.delete(7, { expectedRevision: saved.revision }, '')).rejects.toMatchObject({ status: 400 });
|
|
const deleted = await service.delete(7, { expectedRevision: saved.revision }, 'alex');
|
|
await expect(service.save(7, { ...polygon, expectedRevision: 0 }, 'alex')).rejects.toMatchObject({ status: 409 });
|
|
const restored = await service.save(7, { ...polygon, expectedRevision: deleted.revision }, 'alex');
|
|
expect(restored.revision).toBe(3);
|
|
expect(db.events[2][3]).toBe('restored');
|
|
});
|
|
|
|
test('runtime migration installs dedicated canonical and audit tables', async () => {
|
|
const { migrateSiteGeometry } = require('../src/services/siteGeometryMigration');
|
|
const db = { query: jest.fn().mockResolvedValue([]) };
|
|
await migrateSiteGeometry(db);
|
|
expect(db.query.mock.calls.map(c => c[0]).join('\n')).toMatch(/CREATE TABLE IF NOT EXISTS project_site_geometry /);
|
|
expect(db.query.mock.calls.map(c => c[0]).join('\n')).toMatch(/CREATE TABLE IF NOT EXISTS project_site_geometry_audit /);
|
|
});
|
|
|
|
test('runtime migration skips task geometry basis when the task table is not installed yet', async () => {
|
|
const { migrateSiteGeometry } = require('../src/services/siteGeometryMigration');
|
|
const db = {
|
|
query: jest.fn(async sql => {
|
|
if (/INFORMATION_SCHEMA\.TABLES/.test(sql)) return [];
|
|
if (/ALTER TABLE smart_package_tasks/.test(sql)) {
|
|
throw new Error('must not alter a missing table');
|
|
}
|
|
return [];
|
|
})
|
|
};
|
|
|
|
await expect(migrateSiteGeometry(db)).resolves.toBeUndefined();
|
|
expect(db.query.mock.calls.map(call => call[0]).join('\n')).not.toMatch(/ALTER TABLE smart_package_tasks/);
|
|
});
|
|
|
|
test('runtime migration installs durable global geocoder gate and expiring cache', async () => {
|
|
const { migrateSiteGeometry } = require('../src/services/siteGeometryMigration');
|
|
const db = {
|
|
query: jest.fn(async sql => (
|
|
/INFORMATION_SCHEMA\.TABLES/.test(sql) ? [{ exists: 1 }] : []
|
|
))
|
|
};
|
|
await migrateSiteGeometry(db);
|
|
const sql = db.query.mock.calls.map(c => c[0]).join('\n');
|
|
expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS nominatim_rate_gate/);
|
|
expect(sql).toMatch(/next_request_at DATETIME\(6\) NOT NULL/);
|
|
expect(sql).toMatch(/reservation_token CHAR\(64\) NULL/);
|
|
expect(sql).toMatch(/reservation_expires_at DATETIME\(6\) NULL/);
|
|
expect(sql).toMatch(/INSERT IGNORE INTO nominatim_rate_gate/);
|
|
expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS nominatim_cache/);
|
|
expect(sql).toMatch(/query_key VARBINARY\(800\) PRIMARY KEY/);
|
|
expect(sql).toMatch(/results_json JSON NOT NULL/);
|
|
expect(sql).toMatch(/expires_at DATETIME\(6\) NOT NULL/);
|
|
expect(sql).toMatch(/INDEX cache_expiry \(expires_at\)/);
|
|
expect(sql).toMatch(/ALTER TABLE smart_package_tasks/);
|
|
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS geometry_basis VARCHAR\(50\) NULL/);
|
|
});
|