Files
utils/tests/path-events.spec.ts
ztimson bcc76c7561
All checks were successful
Build / Publish Docs (push) Successful in 43s
Build / Build NPM Project (push) Successful in 1m49s
Build / Tag Version (push) Successful in 8s
Glob support and path scoring
2025-11-27 20:48:04 -05:00

316 lines
9.4 KiB
TypeScript

import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src';
describe('Path Events', () => {
beforeEach(() => {
PathEvent.clearCache();
PathEvent.clearPermissionCache();
});
describe('PE', () => {
it('creates PathEvent from template string', () => {
const e = PE`users/system:cr`;
expect(e).toBeInstanceOf(PathEvent);
expect(e.fullPath).toBe('users/system');
expect(e.create).toBe(true);
expect(e.read).toBe(true);
});
it('handles interpolation', () => {
const path = 'users/system';
const meth = 'r';
const e = PE`${path}:${meth}`;
expect(e.fullPath).toBe('users/system');
expect(e.read).toBe(true);
});
});
describe('PES', () => {
it('creates string for event', () => {
expect(PES`users/system:cr`).toBe('users/system:cr');
});
});
describe('PathEvent', () => {
it('parses event string', () => {
const pe = new PathEvent('users/system:cr');
expect(pe.module).toBe('users');
expect(pe.fullPath).toBe('users/system');
expect(pe.name).toBe('system');
expect(pe.create).toBe(true);
expect(pe.read).toBe(true);
});
it('parses wildcard', () => {
const pe = new PathEvent('*');
expect(pe.all).toBe(true);
expect(pe.fullPath).toBe('**');
expect(pe.methods.has('*')).toBe(true);
});
it('parses empty string as none', () => {
const pe = new PathEvent('');
expect(pe.none).toBe(true);
expect(pe.fullPath).toBe('');
});
it('parses none method', () => {
const pe = new PathEvent('users/system:n');
expect(pe.none).toBe(true);
pe.none = false;
expect(pe.none).toBe(false);
});
it('setters for methods', () => {
const pe = new PathEvent('users/system:r');
pe.create = true;
expect(pe.methods.has('c')).toBe(true);
pe.update = true;
expect(pe.methods.has('u')).toBe(true);
pe.delete = true;
expect(pe.methods.has('d')).toBe(true);
pe.read = false;
expect(pe.methods.has('r')).toBe(false);
});
it('combine merges longest path and methods', () => {
const a = new PathEvent('users/sys:cr');
const b = new PathEvent('users/sys:u');
const c = PathEvent.combine(a, b);
expect(c.fullPath).toBe('users/sys');
expect(c.methods.has('c')).toBe(true);
expect(c.methods.has('r')).toBe(true);
expect(c.methods.has('u')).toBe(true);
});
it('combine stops at none', () => {
const a = new PathEvent('data/collection/doc:c');
const b = new PathEvent('data/collection:r');
const c = new PathEvent('data:n');
const d = PathEvent.combine(a, b, c);
expect(d.fullPath).toBe(a.fullPath);
expect(d.create).toBe(true);
expect(d.read).toBe(true);
expect(d.update).toBe(false);
expect(d.none).toBe(false);
});
it('filter finds overlap by path and methods', () => {
const events = [
new PathEvent('users/sys:cr'),
new PathEvent('users/sys:r'),
new PathEvent('files/sys:r')
];
const filtered = PathEvent.filter(events, 'users/sys:r');
expect(filtered.length).toBe(2);
});
it('filter handles wildcard', () => {
const events = [
new PathEvent('*'),
new PathEvent('users/sys:r')
];
const filtered = PathEvent.filter(events, 'users/sys:r');
expect(filtered.length).toBe(2);
});
it('has returns true for overlapping', () => {
const events = [new PathEvent('users/sys:cr')];
expect(PathEvent.has(events, 'users/sys:r')).toBeTruthy();
expect(PathEvent.has(events, 'users/nope:r')).toBeFalsy();
});
it('hasAll returns true only if all overlap', () => {
const events = [
new PathEvent('users/sys:cr'),
new PathEvent('users/sys:u'),
];
expect(PathEvent.hasAll(events, 'users/sys:c', 'users/sys:u')).toBe(true);
expect(PathEvent.hasAll(events, 'users/sys:c', 'users/sys:no')).toBe(false);
});
it('hasFatal throws if not found', () => {
expect(() => PathEvent.hasFatal('users/sys:r', 'users/other:r')).toThrow(PathError);
expect(() => PathEvent.hasFatal('users/sys:r', 'users/sys:r')).not.toThrow();
});
it('hasAllFatal throws if missing', () => {
expect(() => PathEvent.hasAllFatal(['users/sys:r'], 'users/sys:r', 'users/sys:c')).toThrow(PathError);
});
it('toString creates correct event string', () => {
const s = PathEvent.toString('users/sys', ['c', 'r']);
expect(s).toBe('users/sys:cr');
const pe = new PathEvent('users/sys:cr');
expect(pe.toString()).toBe('users/sys:cr');
});
it('filter instance filters as expected', () => {
const pe = new PathEvent('users/sys:r');
const arr = ['users/sys:r', 'users/other:r'];
const filtered = pe.filter(arr);
expect(filtered[0].fullPath).toBe('users/sys');
});
it('caches PathEvent instances', () => {
const pe1 = new PathEvent('users/sys:cr');
const pe2 = new PathEvent('users/sys:cr');
expect(pe1.fullPath).toBe(pe2.fullPath);
});
it('clearCache removes all cached instances', () => {
new PathEvent('users/sys:cr');
PathEvent.clearCache();
const pe = new PathEvent('users/sys:cr');
expect(pe.fullPath).toBe('users/sys');
});
});
describe('Glob Patterns', () => {
it('** matches zero or more segments', () => {
const pattern = new PathEvent('storage/**:r');
expect(PathEvent.has(pattern, 'storage:r')).toBe(true);
expect(PathEvent.has(pattern, 'storage/file:r')).toBe(true);
expect(PathEvent.has(pattern, 'storage/dir/file:r')).toBe(true);
});
it('** in middle matches multiple segments', () => {
const pattern = new PathEvent('data/**/archive:r');
expect(PathEvent.has(pattern, 'data/archive:r')).toBe(true);
expect(PathEvent.has(pattern, 'data/old/archive:r')).toBe(true);
});
it('filters by glob and methods', () => {
const events = [
new PathEvent('users/123/profile:cr'),
new PathEvent('users/456/profile:r'),
new PathEvent('users/789/settings:r')
];
const filtered = PathEvent.filter(events, 'users/*/profile:r');
expect(filtered.length).toBe(2);
});
it('wildcard method matches all methods', () => {
const pattern = new PathEvent('users/**:*');
expect(PathEvent.has(pattern, 'users/123:c')).toBe(true);
expect(PathEvent.has(pattern, 'users/456:r')).toBe(true);
});
});
describe('Specificity and None Permissions', () => {
it('more specific path wins when sorted by specificity', () => {
const perms = [new PathEvent('users/**:*'), new PathEvent('users/admin:r')];
const combined = PathEvent.combine(...perms);
expect(combined.fullPath).toBe('users/admin');
});
it('none at parent stops combining', () => {
const a = new PathEvent('data/public:cr');
const b = new PathEvent('data:n');
const combined = PathEvent.combine(a, b);
expect(combined.fullPath).toBe('data/public');
expect(combined.read).toBe(true);
});
it('instance methods work correctly', () => {
const pe = new PathEvent('users/sys:r');
expect(pe.has('users/sys:r')).toBe(true);
expect(pe.hasAll('users/sys:r')).toBe(true);
expect(pe.filter(['users/sys:r', 'users/other:r']).length).toBe(1);
});
it('combines methods from hierarchy', () => {
const a = new PathEvent('users/admin:c');
const b = new PathEvent('users/admin:r');
const combined = PathEvent.combine(a, b);
expect(combined.fullPath).toBe('users/admin');
expect(combined.create).toBe(true);
expect(combined.read).toBe(true);
});
});
describe('PathEventEmitter', () => {
it('wildcard', done => {
const emitter = new PathEventEmitter();
emitter.on('*', (event) => {
expect(event.fullPath).toBe('system');
done();
});
emitter.emit('system:c');
});
it('scoped', done => {
const emitter = new PathEventEmitter('users');
emitter.on('*:cud', (event) => {
expect(event.fullPath).toBe('users/system');
done();
});
emitter.emit('system:u');
});
it('calls listener on matching emit', done => {
const emitter = new PathEventEmitter();
const fn = jest.fn((event) => {
expect(event.fullPath).toBe('users/sys');
done();
});
emitter.on('users/sys:r', fn);
emitter.emit('users/sys:r');
});
it('off removes listener', () => {
const emitter = new PathEventEmitter();
const fn = jest.fn();
emitter.on('users/sys:r', fn);
emitter.off(fn);
emitter.emit('users/sys:r');
expect(fn).not.toHaveBeenCalled();
});
it('on returns unsubscribe function', () => {
const emitter = new PathEventEmitter();
const fn = jest.fn();
const unsub = emitter.on('users/sys:r', fn);
unsub();
emitter.emit('users/sys:r');
expect(fn).not.toHaveBeenCalled();
});
it('emit supports prefix', () => {
const emitter = new PathEventEmitter('foo');
emitter.once('*', (event) =>
expect(event.fullPath).toBe('foo/bar'));
emitter.emit('bar:r');
});
it('registers multiple listeners', () => {
const emitter = new PathEventEmitter();
const fn1 = jest.fn();
const fn2 = jest.fn();
emitter.on('users/sys:r', fn1);
emitter.on('users/sys:r', fn2);
emitter.emit('users/sys:r');
expect(fn1).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();
});
it('matches multiple listeners with overlapping globs', () => {
const emitter = new PathEventEmitter();
const fn1 = jest.fn();
const fn2 = jest.fn();
emitter.on('users/**:r', fn1);
emitter.on('users/*/profile:r', fn2);
emitter.emit('users/123/profile:r');
expect(fn1).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();
});
it('glob patterns in listener registration', () => {
const emitter = new PathEventEmitter();
const fn = jest.fn();
emitter.on('users/sys:r', fn);
emitter.emit('users/sys:r');
expect(fn).toHaveBeenCalled();
});
});
});