b6e40b9a2a
The business profile already carried the operator's full issuer block —
address, phone, email, website, VAT id — but none of it reached an email.
Those columns only fed the quote/invoice PDF renderer, so every outgoing
mail footer was the fixed logo + company name + copyright line.
The signature is rendered by wrapEmailHtml and nowhere else, so no
template, no per-type send path and no queue row needed a change. Two new
columns on business_profile (migration 198) carry the toggle and one
free-text legal line; everything else is read from the address fields the
operator already maintains.
Default off, with a test pinning that the disabled path is byte-identical
to a no-profile install.
Includes three rounds of external review fixes: the plain-text MIME part
also carries the signature; string booleans ('false'/'0') no longer
invert the toggle; the status line stays silent rather than asserting
"off" while unauthorised or loading; and the preview's Text tab mirrors
the send path's htmlToText fallback.
Manual Messages replies deliberately keep no signature — they bypass the
wrapper by design — and the UI copy names that exception.
Closes #1264 (Part A)
145 lines
5.6 KiB
JavaScript
145 lines
5.6 KiB
JavaScript
/**
|
|
* PUT /api/admin/business-profile — email signature fields (migration 198).
|
|
*
|
|
* The two new columns are boolean + free text, which is exactly the shape
|
|
* that goes wrong quietly: `optional({ values: 'falsy' })` on the boolean
|
|
* would silently drop `false`, leaving the admin unable to switch the
|
|
* signature back off. The round-trip below is what pins that.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bpsig-test-'));
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
|
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
|
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bpsig-route-test-secret';
|
|
|
|
const request = require('supertest');
|
|
const {
|
|
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
|
} = require('./helpers/crmDb');
|
|
|
|
describe('business profile — email signature round-trip', () => {
|
|
let db;
|
|
let cleanup;
|
|
let app;
|
|
let token;
|
|
|
|
const put = (payload) => request(app)
|
|
.put('/api/admin/business-profile')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send(payload);
|
|
|
|
const get = () => request(app)
|
|
.get('/api/admin/business-profile')
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
// GET returns the snapshot at the top level; PUT wraps it in
|
|
// successResponse's `data` envelope. Read either.
|
|
const profileOf = (res) => (res.body.data || res.body).profile;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
const { adminId } = await seedMinimal(db);
|
|
await assignAdminRole(db, adminId, 'super_admin');
|
|
token = mintAdminToken(adminId);
|
|
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
it('defaults to off with an empty legal line', async () => {
|
|
const res = await get();
|
|
expect(res.status).toBe(200);
|
|
const profile = profileOf(res);
|
|
expect(profile.emailSignatureEnabled).toBe(false);
|
|
expect(profile.emailSignatureExtra).toBe('');
|
|
});
|
|
|
|
it('persists the toggle and the legal line', async () => {
|
|
const res = await put({
|
|
emailSignatureEnabled: true,
|
|
emailSignatureExtra: 'Handelsregister Vaduz FL-0002.123.456-7',
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const profile = profileOf(await get());
|
|
expect(profile.emailSignatureEnabled).toBe(true);
|
|
expect(profile.emailSignatureExtra).toBe('Handelsregister Vaduz FL-0002.123.456-7');
|
|
});
|
|
|
|
it('switches the toggle back off — `false` is not dropped as falsy', async () => {
|
|
await put({ emailSignatureEnabled: true });
|
|
const res = await put({ emailSignatureEnabled: false });
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
|
});
|
|
|
|
it('clears the legal line with an empty string', async () => {
|
|
await put({ emailSignatureExtra: 'something' });
|
|
const res = await put({ emailSignatureExtra: '' });
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(profileOf(await get()).emailSignatureExtra).toBe('');
|
|
});
|
|
|
|
it('trims surrounding whitespace off the legal line', async () => {
|
|
await put({ emailSignatureExtra: ' Registered in Vaduz ' });
|
|
expect(profileOf(await get()).emailSignatureExtra).toBe('Registered in Vaduz');
|
|
});
|
|
|
|
// Codex review: express-validator's isBoolean() accepts the STRINGS
|
|
// 'false' and '0', and Boolean('false') is true — so a form-encoded client
|
|
// trying to switch the signature OFF switched it on instead.
|
|
it.each([['false'], ['0']])('treats the string %s as off, not on', async (value) => {
|
|
await put({ emailSignatureEnabled: true });
|
|
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
|
|
|
const res = await put({ emailSignatureEnabled: value });
|
|
expect(res.status).toBe(200);
|
|
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
|
});
|
|
|
|
it.each([['true'], ['1']])('treats the string %s as on', async (value) => {
|
|
await put({ emailSignatureEnabled: false });
|
|
const res = await put({ emailSignatureEnabled: value });
|
|
expect(res.status).toBe(200);
|
|
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
|
});
|
|
|
|
it('rejects a non-boolean toggle and a legal line over 500 chars', async () => {
|
|
expect((await put({ emailSignatureEnabled: 'yes please' })).status).toBe(400);
|
|
expect((await put({ emailSignatureExtra: 'x'.repeat(501) })).status).toBe(400);
|
|
});
|
|
|
|
it('does not let an unmapped column ride in on the payload', async () => {
|
|
// ALLOWED_PROFILE_FIELDS is the whitelist; the route's camel→snake map
|
|
// is the second gate. Neither should pass a raw snake_case key through.
|
|
const before = await db('business_profile').where({ id: 1 }).first();
|
|
await put({ email_signature_enabled: true, id: 999 });
|
|
const after = await db('business_profile').where({ id: 1 }).first();
|
|
|
|
expect(after.id).toBe(before.id);
|
|
expect(after.email_signature_enabled).toBe(before.email_signature_enabled);
|
|
});
|
|
|
|
it('invalidates the wrapper signature cache on write', async () => {
|
|
const { wrapEmailHtml } = require('../../src/services/emailProcessor');
|
|
|
|
await put({ emailSignatureEnabled: false });
|
|
expect(await wrapEmailHtml('<p>x</p>', 'S')).not.toContain('Bahnhofstrasse 9');
|
|
|
|
// Same request cycle, well inside the 60 s memo window: the PUT must
|
|
// clear the cache or the operator sees a stale footer for a minute.
|
|
await put({ emailSignatureEnabled: true, addressLine1: 'Bahnhofstrasse 9' });
|
|
expect(await wrapEmailHtml('<p>x</p>', 'S')).toContain('Bahnhofstrasse 9');
|
|
});
|
|
});
|