Merge MVP startup defaults with memory-db mode + tests
This commit is contained in:
139
test/smoke.test.js
Normal file
139
test/smoke.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const rootDir = process.cwd();
|
||||
|
||||
test('web and worker boot cleanly in direct node mode', (t) => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mastermind-startup-'));
|
||||
const env = {
|
||||
...process.env,
|
||||
DATA_DIR: dataDir,
|
||||
DATABASE_URL: 'memory://smoke',
|
||||
SESSION_SECRET: 'smoke-test-secret',
|
||||
BOOTSTRAP_OWNER_EMAIL: 'owner@local',
|
||||
BOOTSTRAP_OWNER_PASSWORD: 'owner',
|
||||
MASTERMIND_DISABLE_LISTEN: '1',
|
||||
NODE_ENV: 'test',
|
||||
WORKER_TICK_MS: '100',
|
||||
WORKER_RUN_ONCE: '1'
|
||||
};
|
||||
|
||||
t.after(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const web = spawnSync(process.execPath, ['web/src/index.js'], {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
const worker = spawnSync(process.execPath, ['worker/src/worker.js'], {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
assert.equal(web.status, 0, web.stderr || web.stdout);
|
||||
assert.equal(worker.status, 0, worker.stderr || worker.stdout);
|
||||
});
|
||||
|
||||
test('memory-backed flow supports project creation, email sorting, and draft generation', async () => {
|
||||
const [{ createMemoryPool }, { applyRulesToEmail }, { buildPCOBody }] = await Promise.all([
|
||||
import('../web/src/lib/memory-db.js'),
|
||||
import('../web/src/lib/email-rules.js'),
|
||||
import('../web/src/lib/utils.js')
|
||||
]);
|
||||
|
||||
const pool = createMemoryPool();
|
||||
|
||||
const ownerInsert = await pool.query(
|
||||
"insert into users(email, display_name, role) values ($1,'Owner','owner') returning id",
|
||||
['owner@local']
|
||||
);
|
||||
const ownerId = ownerInsert.rows[0].id;
|
||||
await pool.query(
|
||||
"insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)",
|
||||
[ownerId, 'owner@local', 'hash']
|
||||
);
|
||||
|
||||
const projectInsert = await pool.query(
|
||||
`insert into projects(created_by_user_id,name,job_number,role_mode,gc_name,city,state,keywords)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
returning id`,
|
||||
[ownerId, 'Smoke Test Project', '0222600001', 'ec', 'Example GC', 'Durham', 'NC', 'smoke']
|
||||
);
|
||||
const projectId = projectInsert.rows[0].id;
|
||||
await pool.query(
|
||||
`insert into project_members(project_id,user_id,project_role) values ($1,$2,'owner')
|
||||
on conflict do nothing`,
|
||||
[projectId, ownerId]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`insert into email_rules(created_by_user_id, project_id, match_type, match_value, priority)
|
||||
values ($1,$2,$3,$4,$5)
|
||||
returning id`,
|
||||
[ownerId, projectId, 'subject_contains', '0222600001', 10]
|
||||
);
|
||||
|
||||
const emailInsert = await pool.query(
|
||||
`insert into ingested_emails(created_by_user_id, source, from_addr, to_addr, cc_addr, subject, date, body_text, body_html, has_attachments, raw_path, sha256, status)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'unsorted')
|
||||
returning *`,
|
||||
[
|
||||
ownerId,
|
||||
'upload',
|
||||
'pm@example.com',
|
||||
'team@example.com',
|
||||
'',
|
||||
'RE: 0222600001 coordination',
|
||||
new Date('2026-03-01T12:00:00.000Z'),
|
||||
'Need pricing and schedule impact.',
|
||||
'',
|
||||
false,
|
||||
'/tmp/sample.eml',
|
||||
'abc123'
|
||||
]
|
||||
);
|
||||
const email = emailInsert.rows[0];
|
||||
|
||||
const rules = (await pool.query(
|
||||
`select * from email_rules where enabled=true order by priority asc, created_at asc`
|
||||
)).rows;
|
||||
const match = await applyRulesToEmail(email, rules);
|
||||
assert.deepEqual(
|
||||
{ projectId: match.projectId, confidence: match.confidence },
|
||||
{ projectId, confidence: 0.9 }
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`update ingested_emails set project_id=$1, status=$2, confidence=$3 where id=$4`,
|
||||
[projectId, 'assigned', match.confidence, email.id]
|
||||
);
|
||||
|
||||
const assignedEmails = await pool.query(
|
||||
`select id, from_addr, subject, date, source
|
||||
from ingested_emails
|
||||
where project_id=$1
|
||||
order by date desc nulls last, created_at desc
|
||||
limit 300`,
|
||||
[projectId]
|
||||
);
|
||||
assert.equal(assignedEmails.rows.length, 1);
|
||||
assert.equal(assignedEmails.rows[0].subject, 'RE: 0222600001 coordination');
|
||||
|
||||
const body = buildPCOBody(email, { name: 'Smoke Test Project', job_number: '0222600001' });
|
||||
const draftInsert = await pool.query(
|
||||
`insert into pco_drafts(created_by_user_id, project_id, source_email_id, title, body)
|
||||
values ($1,$2,$3,$4,$5) returning id`,
|
||||
[ownerId, projectId, email.id, 'PCO: RE: 0222600001 coordination', body]
|
||||
);
|
||||
|
||||
const draft = await pool.query('select * from pco_drafts where id=$1', [draftInsert.rows[0].id]);
|
||||
assert.equal(draft.rows[0].source_email_id, email.id);
|
||||
assert.match(draft.rows[0].body, /Need pricing and schedule impact\./);
|
||||
});
|
||||
47
test/utils.test.js
Normal file
47
test/utils.test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
let utilsPromise;
|
||||
|
||||
async function loadUtils() {
|
||||
utilsPromise ||= import('../web/src/lib/utils.js');
|
||||
return utilsPromise;
|
||||
}
|
||||
|
||||
test('extractDomain returns lower-cased sender domain', async () => {
|
||||
const { extractDomain } = await loadUtils();
|
||||
assert.equal(extractDomain('Jane Doe <Jane.Doe@Example.COM>'), 'example.com');
|
||||
assert.equal(extractDomain('no-at-symbol'), '');
|
||||
});
|
||||
|
||||
test('extractFirstJobNumber finds the first long numeric token', async () => {
|
||||
const { extractFirstJobNumber } = await loadUtils();
|
||||
assert.equal(extractFirstJobNumber('RE: Job 0222600001 addendum'), '0222600001');
|
||||
assert.equal(extractFirstJobNumber('no job here'), '');
|
||||
});
|
||||
|
||||
test('validatePassword enforces the expected complexity rules', async () => {
|
||||
const { validatePassword } = await loadUtils();
|
||||
assert.equal(validatePassword('short'), 'Password must be at least 12 characters.');
|
||||
assert.equal(validatePassword('alllowercase123'), 'Password must include an uppercase letter.');
|
||||
assert.equal(validatePassword('ALLUPPERCASE123'), 'Password must include a lowercase letter.');
|
||||
assert.equal(validatePassword('MissingNumber!'), 'Password must include a number.');
|
||||
assert.equal(validatePassword('ValidPassword123'), null);
|
||||
});
|
||||
|
||||
test('draft builders include source email context', async () => {
|
||||
const { buildPCOBody, buildRFIBody } = await loadUtils();
|
||||
const email = {
|
||||
id: 'email-1',
|
||||
subject: 'Coordination needed',
|
||||
from_addr: 'pm@example.com',
|
||||
date: '2026-03-01T00:00:00.000Z',
|
||||
body_text: 'Need a price and schedule impact.'
|
||||
};
|
||||
const project = { name: 'Example Project', job_number: '0222600001' };
|
||||
|
||||
assert.match(buildPCOBody(email, project), /Potential Change Order/);
|
||||
assert.match(buildPCOBody(email, project), /Coordination needed/);
|
||||
assert.match(buildRFIBody(email, project), /RFI - Draft/);
|
||||
assert.match(buildRFIBody(email, project), /Need a price and schedule impact\./);
|
||||
});
|
||||
Reference in New Issue
Block a user