mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-09 05:24:58 +09:00
127 lines
5.8 KiB
TypeScript
127 lines
5.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { completeApiAction } from '../api-completion.ts';
|
|
import flashMessage, { queueFlashMessage } from '../flash.ts';
|
|
|
|
function fixture(run: (state: ReturnType<typeof setup>) => void) {
|
|
const original = {window: globalThis.window, document: globalThis.document,
|
|
runtime: (globalThis as any).FOSSBilling, storage: (globalThis as any).sessionStorage, warn: console.warn};
|
|
const state = setup();
|
|
Object.assign(globalThis, {window: state.window, document: state.document, FOSSBilling: state.runtime, sessionStorage: state.storage});
|
|
console.warn = (...args: unknown[]) => state.warnings.push(args);
|
|
try {run(state);} finally {
|
|
Object.assign(globalThis, {window: original.window, document: original.document, FOSSBilling: original.runtime, sessionStorage: original.storage});
|
|
console.warn = original.warn;
|
|
}
|
|
}
|
|
|
|
function setup() {
|
|
const saved = new Map<string, string>();
|
|
const shown: unknown[][] = [];
|
|
const navigation: string[] = [];
|
|
const warnings: unknown[][] = [];
|
|
const runtime = {ui: {notify: (...args: unknown[]) => shown.push(args)}};
|
|
const location = {href: 'https://billing.example/client/profile', origin: 'https://billing.example',
|
|
reload: () => navigation.push('reload'), assign: (url: string) => navigation.push(url)};
|
|
const window = {location, FOSSBilling: runtime} as any;
|
|
const document = {querySelector: () => ({dataset: {formUpdated: 'Translated Changes Saved'}})};
|
|
const storage = {getItem: (key: string) => saved.get(key) ?? null,
|
|
setItem: (key: string, value: string) => saved.set(key, value), removeItem: (key: string) => saved.delete(key)};
|
|
return {saved, shown, navigation, warnings, runtime, window, document, storage};
|
|
}
|
|
|
|
test('extension callbacks retain their window context and own feedback and navigation', () => fixture(f => {
|
|
const result = {id: 7};
|
|
f.window.afterSave = function (value: unknown) {
|
|
assert.equal(this, f.window);
|
|
assert.equal(value, result);
|
|
return 'handled';
|
|
};
|
|
assert.equal(completeApiAction({callback: 'afterSave', message: 'Saved', reload: true}, result), 'handled');
|
|
assert.deepEqual(f.shown, []);
|
|
assert.deepEqual(f.navigation, []);
|
|
assert.equal(f.saved.size, 0);
|
|
}));
|
|
|
|
test('invalid callbacks warn and still allow the declared completion behaviour', () => fixture(f => {
|
|
completeApiAction({callback: 'missingExtension', message: 'Saved'}, {});
|
|
assert.equal(f.warnings.length, 1);
|
|
assert.deepEqual(f.shown, [['Saved', 'success']]);
|
|
}));
|
|
|
|
test('explicit success messages survive reload and are consumed exactly once', () => fixture(f => {
|
|
completeApiAction({reload: true, message: 'Settings Saved'}, {});
|
|
assert.deepEqual(f.navigation, ['reload']);
|
|
assert.deepEqual(f.shown, []);
|
|
assert.equal(f.saved.get('flash-message'), 'Settings Saved');
|
|
assert.equal(f.saved.get('flash-message-type'), 'success');
|
|
flashMessage(); flashMessage();
|
|
assert.deepEqual(f.shown, [['Settings Saved', 'success']]);
|
|
assert.equal(f.saved.size, 0);
|
|
}));
|
|
|
|
test('relative same-origin redirects retain the supplied message and severity', () => fixture(f => {
|
|
completeApiAction({redirect: '/invoice/7#payment', message: 'Payment Submitted'}, {});
|
|
assert.deepEqual(f.navigation, ['https://billing.example/invoice/7#payment']);
|
|
assert.deepEqual(f.shown, []);
|
|
flashMessage();
|
|
assert.deepEqual(f.shown, [['Payment Submitted', 'success']]);
|
|
}));
|
|
|
|
test('redirecting to the current URL preserves reload semantics', () => fixture(f => {
|
|
completeApiAction({redirect: f.window.location.href, message: 'Saved'}, {});
|
|
assert.deepEqual(f.navigation, ['reload']);
|
|
assert.equal(f.saved.get('flash-message'), 'Saved');
|
|
}));
|
|
|
|
test('external navigation does not leave success feedback waiting on an unrelated return visit', () => fixture(f => {
|
|
completeApiAction({redirect: 'https://gateway.example/pay', message: 'Continue to Payment'}, {});
|
|
assert.deepEqual(f.navigation, ['https://gateway.example/pay']);
|
|
assert.equal(f.saved.size, 0);
|
|
}));
|
|
|
|
test('navigation without an explicit message stays silent and reload flags keep their legacy contract', () => fixture(f => {
|
|
completeApiAction({reload: false}, {});
|
|
assert.deepEqual(f.navigation, ['reload']);
|
|
assert.deepEqual(f.shown, []);
|
|
assert.equal(f.saved.size, 0);
|
|
}));
|
|
|
|
test('inline messages show once and an explicit empty message suppresses generic feedback', () => fixture(f => {
|
|
completeApiAction({message: 'Saved <details>'}, false);
|
|
completeApiAction({message: ''}, {});
|
|
assert.deepEqual(f.shown, [['Saved <details>', 'success']]);
|
|
assert.equal(f.saved.size, 0);
|
|
}));
|
|
|
|
test('default success labels are translated while unhandled empty outcomes stay unchanged', () => fixture(f => {
|
|
completeApiAction({}, {id: 1});
|
|
assert.deepEqual(f.shown, [['Translated Changes Saved', 'success']]);
|
|
completeApiAction({}, null);
|
|
assert.equal(f.shown.length, 1);
|
|
assert.equal(f.warnings.length, 1);
|
|
}));
|
|
|
|
test('blocked storage never prevents navigation', () => fixture(f => {
|
|
f.storage.setItem = () => {throw new Error('Blocked');};
|
|
assert.doesNotThrow(() => completeApiAction({message: 'Saved', reload: true}, {}));
|
|
assert.deepEqual(f.navigation, ['reload']);
|
|
}));
|
|
|
|
test('partial flash writes do not pair a new message with stale severity', () => fixture(f => {
|
|
f.saved.set('flash-message-type', 'error');
|
|
f.storage.setItem = (key: string, value: string) => {
|
|
if (key === 'flash-message-type') throw new Error('Quota exceeded');
|
|
f.saved.set(key, value);
|
|
return f.saved;
|
|
};
|
|
queueFlashMessage('Saved', 'success');
|
|
assert.equal(f.saved.size, 0);
|
|
}));
|
|
|
|
test('legacy flash payloads remain plain text and respect extension notification hooks', () => fixture(f => {
|
|
f.saved.set('flash-message', '<img src=x onerror=bad()>');
|
|
flashMessage();
|
|
assert.deepEqual(f.shown, [['<img src=x onerror=bad()>', 'info']]);
|
|
}));
|