test: add suites for time, matrix, mimeTypes, and search filters (+47 tests)
Expands pure-logic coverage (harness: tsx + node:test): - utils/time (21): date/time formatters — exact values where timezone-independent, structure/regex where locale/tz-sensitive (written via subagent). - utils/matrix (13): pure id/mxc helpers (isUserId/isRoomId/isRoomAlias/ getMxIdLocalPart/getMxIdServer/isServerName + room-version gates). (subagent) - utils/mimeTypes (7): getBlobSafeMimeType allowlist+remap, safeFile rewrap, mimeTypeToExt, getFileNameExt/WithoutExt edge cases. - message-search filters (6): filterGroupsByMsgType (union, empty-group drop, non-string msgtype) + filterGroupsByPinned (disabled passthrough, pinned-only). All assertions verified against actual runtime behavior. Suite now 74 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
|
||||
|
||||
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
|
||||
// (event.content.msgtype, event.event_id, group.roomId).
|
||||
const item = (msgtype: string | undefined, eventId: string) => ({
|
||||
rank: 1,
|
||||
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
|
||||
context: {},
|
||||
});
|
||||
const mkGroups = (
|
||||
...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
|
||||
): ResultGroup[] => groups as unknown as ResultGroup[];
|
||||
|
||||
test('filterGroupsByMsgType: empty filter returns groups unchanged', () => {
|
||||
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1'), item('m.image', '$2')] });
|
||||
assert.equal(filterGroupsByMsgType(groups, []), groups);
|
||||
});
|
||||
|
||||
test('filterGroupsByMsgType: keeps only matching msgtypes (union)', () => {
|
||||
const groups = mkGroups({
|
||||
roomId: '!r1',
|
||||
items: [item('m.text', '$1'), item('m.image', '$2'), item('m.file', '$3')],
|
||||
});
|
||||
const out = filterGroupsByMsgType(groups, ['m.image', 'm.file']);
|
||||
assert.equal(out.length, 1);
|
||||
assert.deepEqual(
|
||||
out[0].items.map((i) => i.event.event_id),
|
||||
['$2', '$3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('filterGroupsByMsgType: drops groups left empty', () => {
|
||||
const groups = mkGroups(
|
||||
{ roomId: '!r1', items: [item('m.text', '$1')] },
|
||||
{ roomId: '!r2', items: [item('m.image', '$2')] },
|
||||
);
|
||||
const out = filterGroupsByMsgType(groups, ['m.image']);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].roomId, '!r2');
|
||||
});
|
||||
|
||||
test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
|
||||
const groups = mkGroups({ roomId: '!r1', items: [item(undefined, '$1'), item('m.video', '$2')] });
|
||||
const out = filterGroupsByMsgType(groups, ['m.video']);
|
||||
assert.equal(out[0].items.length, 1);
|
||||
assert.equal(out[0].items[0].event.event_id, '$2');
|
||||
});
|
||||
|
||||
test('filterGroupsByPinned: disabled returns groups unchanged', () => {
|
||||
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
|
||||
assert.equal(
|
||||
filterGroupsByPinned(groups, false, () => false),
|
||||
groups,
|
||||
);
|
||||
});
|
||||
|
||||
test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
|
||||
const groups = mkGroups(
|
||||
{ roomId: '!r1', items: [item('m.text', '$1'), item('m.text', '$2')] },
|
||||
{ roomId: '!r2', items: [item('m.text', '$3')] },
|
||||
);
|
||||
const pinned = new Set(['!r1/$2']);
|
||||
const out = filterGroupsByPinned(groups, true, (roomId, eventId) =>
|
||||
pinned.has(`${roomId}/${eventId}`),
|
||||
);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].roomId, '!r1');
|
||||
assert.deepEqual(
|
||||
out[0].items.map((i) => i.event.event_id),
|
||||
['$2'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isServerName,
|
||||
getMxIdServer,
|
||||
getMxIdLocalPart,
|
||||
isUserId,
|
||||
isRoomId,
|
||||
isRoomAlias,
|
||||
knockSupported,
|
||||
restrictedSupported,
|
||||
knockRestrictedSupported,
|
||||
creatorsSupported,
|
||||
} from './matrix';
|
||||
|
||||
test('isServerName matches domain-like strings', () => {
|
||||
assert.equal(isServerName('matrix.org'), true);
|
||||
assert.equal(isServerName('a.b.example.com'), true);
|
||||
assert.equal(isServerName('my-server.io'), true);
|
||||
// The regex looks for a domain substring anywhere, so embedded domains match.
|
||||
assert.equal(isServerName('user@matrix.org'), true);
|
||||
assert.equal(isServerName('matrix.org:8448'), true);
|
||||
});
|
||||
|
||||
test('isServerName rejects strings without a dotted TLD', () => {
|
||||
assert.equal(isServerName('localhost'), false);
|
||||
assert.equal(isServerName(''), false);
|
||||
assert.equal(isServerName('nodot'), false);
|
||||
// TLD must be at least two letters.
|
||||
assert.equal(isServerName('a.b'), false);
|
||||
// Numeric "TLD" is not matched by the [a-zA-Z]{2,} part.
|
||||
assert.equal(isServerName('127.0.0.1'), false);
|
||||
});
|
||||
|
||||
test('getMxIdServer extracts the server portion after the colon', () => {
|
||||
assert.equal(getMxIdServer('@alice:matrix.org'), 'matrix.org');
|
||||
assert.equal(getMxIdServer('#room:example.com'), 'example.com');
|
||||
assert.equal(getMxIdServer('+group:server.io'), 'server.io');
|
||||
assert.equal(getMxIdServer('$event:host.net'), 'host.net');
|
||||
});
|
||||
|
||||
test('getMxIdServer returns undefined for ids that do not match', () => {
|
||||
// '!' is not an accepted sigil in the matcher.
|
||||
assert.equal(getMxIdServer('!roomid:matrix.org'), undefined);
|
||||
assert.equal(getMxIdServer('alice:matrix.org'), undefined);
|
||||
assert.equal(getMxIdServer('@alice'), undefined);
|
||||
assert.equal(getMxIdServer(''), undefined);
|
||||
// A space in the local part breaks the [^\s:]+ group.
|
||||
assert.equal(getMxIdServer('@al ice:matrix.org'), undefined);
|
||||
});
|
||||
|
||||
test('getMxIdLocalPart extracts the local part between sigil and colon', () => {
|
||||
assert.equal(getMxIdLocalPart('@alice:matrix.org'), 'alice');
|
||||
assert.equal(getMxIdLocalPart('#general:example.com'), 'general');
|
||||
assert.equal(getMxIdLocalPart('+group:server.io'), 'group');
|
||||
assert.equal(getMxIdLocalPart('$event:host.net'), 'event');
|
||||
});
|
||||
|
||||
test('getMxIdLocalPart returns undefined for non-matching ids', () => {
|
||||
assert.equal(getMxIdLocalPart('!roomid:matrix.org'), undefined);
|
||||
assert.equal(getMxIdLocalPart('alice'), undefined);
|
||||
assert.equal(getMxIdLocalPart('@alice'), undefined);
|
||||
assert.equal(getMxIdLocalPart(''), undefined);
|
||||
});
|
||||
|
||||
test('isUserId requires a valid mxid starting with @', () => {
|
||||
assert.equal(isUserId('@alice:matrix.org'), true);
|
||||
assert.equal(isUserId('@bob:example.com'), true);
|
||||
// Valid mxid shape but wrong sigil.
|
||||
assert.equal(isUserId('#room:matrix.org'), false);
|
||||
assert.equal(isUserId('+group:matrix.org'), false);
|
||||
// Starts with @ but has no server (invalid mxid).
|
||||
assert.equal(isUserId('@alice'), false);
|
||||
assert.equal(isUserId('alice:matrix.org'), false);
|
||||
assert.equal(isUserId(''), false);
|
||||
});
|
||||
|
||||
test('isRoomId only checks the leading ! sigil', () => {
|
||||
assert.equal(isRoomId('!abc:matrix.org'), true);
|
||||
// No validity check is performed, so a bare '!' passes.
|
||||
assert.equal(isRoomId('!'), true);
|
||||
assert.equal(isRoomId('!anything-at-all'), true);
|
||||
assert.equal(isRoomId('@alice:matrix.org'), false);
|
||||
assert.equal(isRoomId('#room:matrix.org'), false);
|
||||
assert.equal(isRoomId(''), false);
|
||||
});
|
||||
|
||||
test('isRoomAlias requires a valid mxid starting with #', () => {
|
||||
assert.equal(isRoomAlias('#general:matrix.org'), true);
|
||||
assert.equal(isRoomAlias('#room:example.com'), true);
|
||||
// Wrong sigils.
|
||||
assert.equal(isRoomAlias('@alice:matrix.org'), false);
|
||||
assert.equal(isRoomAlias('!room:matrix.org'), false);
|
||||
// Starts with # but missing server part.
|
||||
assert.equal(isRoomAlias('#general'), false);
|
||||
assert.equal(isRoomAlias(''), false);
|
||||
});
|
||||
|
||||
test('knockSupported gates room versions 1-6 out', () => {
|
||||
['1', '2', '3', '4', '5', '6'].forEach((v) => {
|
||||
assert.equal(knockSupported(v), false, `version ${v}`);
|
||||
});
|
||||
assert.equal(knockSupported('7'), true);
|
||||
assert.equal(knockSupported('8'), true);
|
||||
assert.equal(knockSupported('10'), true);
|
||||
// Unknown / non-numeric versions are treated as supported.
|
||||
assert.equal(knockSupported('org.matrix.msc'), true);
|
||||
});
|
||||
|
||||
test('restrictedSupported gates room versions 1-7 out', () => {
|
||||
['1', '2', '3', '4', '5', '6', '7'].forEach((v) => {
|
||||
assert.equal(restrictedSupported(v), false, `version ${v}`);
|
||||
});
|
||||
assert.equal(restrictedSupported('8'), true);
|
||||
assert.equal(restrictedSupported('9'), true);
|
||||
});
|
||||
|
||||
test('knockRestrictedSupported gates room versions 1-9 out', () => {
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9'].forEach((v) => {
|
||||
assert.equal(knockRestrictedSupported(v), false, `version ${v}`);
|
||||
});
|
||||
assert.equal(knockRestrictedSupported('10'), true);
|
||||
assert.equal(knockRestrictedSupported('11'), true);
|
||||
});
|
||||
|
||||
test('creatorsSupported gates room versions 1-11 out', () => {
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'].forEach((v) => {
|
||||
assert.equal(creatorsSupported(v), false, `version ${v}`);
|
||||
});
|
||||
assert.equal(creatorsSupported('12'), true);
|
||||
assert.equal(creatorsSupported('13'), true);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ALLOWED_BLOB_MIME_TYPES,
|
||||
FALLBACK_MIMETYPE,
|
||||
getBlobSafeMimeType,
|
||||
safeFile,
|
||||
mimeTypeToExt,
|
||||
getFileNameExt,
|
||||
getFileNameWithoutExt,
|
||||
} from './mimeTypes';
|
||||
|
||||
test('getBlobSafeMimeType falls back for disallowed types', () => {
|
||||
assert.equal(getBlobSafeMimeType('application/x-totally-not-allowed'), FALLBACK_MIMETYPE);
|
||||
// non-string input is coerced to the fallback
|
||||
assert.equal(getBlobSafeMimeType(undefined as unknown as string), FALLBACK_MIMETYPE);
|
||||
});
|
||||
|
||||
test('getBlobSafeMimeType passes allowed types and strips codec params', () => {
|
||||
const allowed = ALLOWED_BLOB_MIME_TYPES[0];
|
||||
assert.equal(getBlobSafeMimeType(allowed), allowed);
|
||||
// everything after the first ';' is dropped
|
||||
assert.equal(getBlobSafeMimeType(`${allowed}; charset=utf-8`), allowed);
|
||||
});
|
||||
|
||||
test('getBlobSafeMimeType remaps quicktime/ogg (when allowlisted)', () => {
|
||||
if (ALLOWED_BLOB_MIME_TYPES.includes('video/quicktime')) {
|
||||
assert.equal(getBlobSafeMimeType('video/quicktime'), 'video/mp4');
|
||||
}
|
||||
if (ALLOWED_BLOB_MIME_TYPES.includes('application/ogg')) {
|
||||
assert.equal(getBlobSafeMimeType('application/ogg'), 'audio/ogg');
|
||||
}
|
||||
});
|
||||
|
||||
test('safeFile rewraps a file whose type is not blob-safe', () => {
|
||||
const f = new File(['x'], 'note.txt', { type: 'application/x-bad' });
|
||||
const safe = safeFile(f);
|
||||
assert.equal(safe.type, FALLBACK_MIMETYPE);
|
||||
assert.equal(safe.name, 'note.txt');
|
||||
});
|
||||
|
||||
test('mimeTypeToExt returns the subtype', () => {
|
||||
assert.equal(mimeTypeToExt('image/png'), 'png');
|
||||
assert.equal(mimeTypeToExt('application/vnd.ms-excel'), 'vnd.ms-excel');
|
||||
});
|
||||
|
||||
test('getFileNameExt', () => {
|
||||
assert.equal(getFileNameExt('photo.PNG'), 'PNG');
|
||||
assert.equal(getFileNameExt('archive.tar.gz'), 'gz');
|
||||
// no dot → returns the whole name (lastIndexOf('.') === -1 → slice(0))
|
||||
assert.equal(getFileNameExt('noext'), 'noext');
|
||||
});
|
||||
|
||||
test('getFileNameWithoutExt', () => {
|
||||
assert.equal(getFileNameWithoutExt('photo.png'), 'photo');
|
||||
assert.equal(getFileNameWithoutExt('a.b.c'), 'a.b');
|
||||
assert.equal(getFileNameWithoutExt('noext'), 'noext');
|
||||
// a leading-dot dotfile keeps its name (extStart === 0)
|
||||
assert.equal(getFileNameWithoutExt('.hidden'), '.hidden');
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
today,
|
||||
yesterday,
|
||||
timeHour,
|
||||
timeMinute,
|
||||
timeAmPm,
|
||||
timeDay,
|
||||
timeMon,
|
||||
timeMonth,
|
||||
timeYear,
|
||||
timeHourMinute,
|
||||
timeDayMonYear,
|
||||
timeDayMonthYear,
|
||||
daysInMonth,
|
||||
dateFor,
|
||||
inSameDay,
|
||||
minuteDifference,
|
||||
hour24to12,
|
||||
hour12to24,
|
||||
secondsToMs,
|
||||
minutesToMs,
|
||||
hoursToMs,
|
||||
daysToMs,
|
||||
getToday,
|
||||
getYesterday,
|
||||
} from './time';
|
||||
|
||||
test('today is true for now and false for other days', () => {
|
||||
assert.equal(today(Date.now()), true);
|
||||
assert.equal(today(dayjs().subtract(1, 'day').valueOf()), false);
|
||||
assert.equal(today(dayjs().add(1, 'day').valueOf()), false);
|
||||
});
|
||||
|
||||
test('yesterday is true only for the previous day', () => {
|
||||
assert.equal(yesterday(dayjs().subtract(1, 'day').valueOf()), true);
|
||||
assert.equal(yesterday(Date.now()), false);
|
||||
assert.equal(yesterday(dayjs().subtract(2, 'day').valueOf()), false);
|
||||
});
|
||||
|
||||
test('timeHour formats 24h and 12h zero-padded', () => {
|
||||
const ts = Date.now();
|
||||
// 24-hour clock: two digits 00-23
|
||||
assert.match(timeHour(ts, true), /^([01]\d|2[0-3])$/);
|
||||
// 12-hour clock: two digits 01-12
|
||||
assert.match(timeHour(ts, false), /^(0[1-9]|1[0-2])$/);
|
||||
});
|
||||
|
||||
test('timeMinute is two digits 00-59', () => {
|
||||
assert.match(timeMinute(Date.now()), /^[0-5]\d$/);
|
||||
});
|
||||
|
||||
test('timeAmPm is AM or PM', () => {
|
||||
assert.match(timeAmPm(Date.now()), /^(AM|PM)$/);
|
||||
});
|
||||
|
||||
test('timeDay is day-of-month without leading zero', () => {
|
||||
const out = timeDay(Date.now());
|
||||
assert.match(out, /^([1-9]|[12]\d|3[01])$/);
|
||||
});
|
||||
|
||||
test('timeMon is a three-letter month abbreviation', () => {
|
||||
// Use a fixed UTC timestamp far from month boundaries to avoid tz drift.
|
||||
const midMonth = dayjs('2021-06-15T12:00:00Z').valueOf();
|
||||
assert.equal(timeMon(midMonth), 'Jun');
|
||||
assert.match(timeMon(Date.now()), /^[A-Z][a-z]{2}$/);
|
||||
});
|
||||
|
||||
test('timeMonth is a full month name', () => {
|
||||
const midMonth = dayjs('2021-06-15T12:00:00Z').valueOf();
|
||||
assert.equal(timeMonth(midMonth), 'June');
|
||||
});
|
||||
|
||||
test('timeYear is a four-digit year', () => {
|
||||
const ts = dayjs('2021-06-15T12:00:00Z').valueOf();
|
||||
assert.equal(timeYear(ts), '2021');
|
||||
assert.match(timeYear(Date.now()), /^\d{4}$/);
|
||||
});
|
||||
|
||||
test('timeHourMinute structure for 24h and 12h', () => {
|
||||
const ts = Date.now();
|
||||
assert.match(timeHourMinute(ts, true), /^([01]\d|2[0-3]):[0-5]\d$/);
|
||||
assert.match(timeHourMinute(ts, false), /^(0[1-9]|1[0-2]):[0-5]\d (AM|PM)$/);
|
||||
});
|
||||
|
||||
test('timeDayMonYear honors the provided format string', () => {
|
||||
const ts = dayjs('2021-06-15T12:00:00Z').valueOf();
|
||||
assert.equal(timeDayMonYear(ts, 'YYYY'), '2021');
|
||||
assert.equal(timeDayMonYear(ts, 'MMMM'), 'June');
|
||||
});
|
||||
|
||||
test('timeDayMonthYear shape is "D MMMM YYYY"', () => {
|
||||
assert.match(timeDayMonthYear(Date.now()), /^([1-9]|[12]\d|3[01]) [A-Z][a-z]+ \d{4}$/);
|
||||
});
|
||||
|
||||
test('daysInMonth returns correct counts', () => {
|
||||
assert.equal(daysInMonth(2, 2020), 29); // leap year February
|
||||
assert.equal(daysInMonth(2, 2021), 28);
|
||||
assert.equal(daysInMonth(4, 2021), 30); // April
|
||||
assert.equal(daysInMonth(1, 2021), 31); // January
|
||||
});
|
||||
|
||||
test('dateFor returns a timestamp matching the given date', () => {
|
||||
const ts = dateFor(2021, 6, 15);
|
||||
const d = dayjs(ts);
|
||||
assert.equal(d.year(), 2021);
|
||||
assert.equal(d.month() + 1, 6);
|
||||
assert.equal(d.date(), 15);
|
||||
});
|
||||
|
||||
test('inSameDay compares calendar days in local time', () => {
|
||||
const a = new Date(2021, 5, 15, 1, 0, 0).getTime();
|
||||
const b = new Date(2021, 5, 15, 23, 0, 0).getTime();
|
||||
const c = new Date(2021, 5, 16, 0, 0, 0).getTime();
|
||||
assert.equal(inSameDay(a, b), true);
|
||||
assert.equal(inSameDay(a, c), false);
|
||||
});
|
||||
|
||||
test('minuteDifference is absolute and rounded', () => {
|
||||
const base = 1_600_000_000_000;
|
||||
assert.equal(minuteDifference(base, base + 60_000), 1);
|
||||
assert.equal(minuteDifference(base + 60_000, base), 1); // absolute value
|
||||
assert.equal(minuteDifference(base, base + 90_000), 2); // rounds 1.5 -> 2
|
||||
assert.equal(minuteDifference(base, base), 0);
|
||||
});
|
||||
|
||||
test('hour24to12 maps 24h to 12h clock', () => {
|
||||
assert.equal(hour24to12(0), 12);
|
||||
assert.equal(hour24to12(12), 12);
|
||||
assert.equal(hour24to12(13), 1);
|
||||
assert.equal(hour24to12(23), 11);
|
||||
assert.equal(hour24to12(9), 9);
|
||||
});
|
||||
|
||||
test('hour12to24 maps 12h clock to 24h', () => {
|
||||
assert.equal(hour12to24(12, false), 0); // 12 AM -> 0
|
||||
assert.equal(hour12to24(12, true), 12); // 12 PM -> 12
|
||||
assert.equal(hour12to24(1, false), 1); // 1 AM
|
||||
assert.equal(hour12to24(1, true), 13); // 1 PM
|
||||
assert.equal(hour12to24(11, true), 23); // 11 PM
|
||||
});
|
||||
|
||||
test('millisecond conversion helpers', () => {
|
||||
assert.equal(secondsToMs(1), 1000);
|
||||
assert.equal(minutesToMs(1), 60_000);
|
||||
assert.equal(hoursToMs(1), 3_600_000);
|
||||
assert.equal(daysToMs(1), 86_400_000);
|
||||
assert.equal(daysToMs(2), 172_800_000);
|
||||
});
|
||||
|
||||
test('getToday returns the start-of-today timestamp', () => {
|
||||
const ts = getToday();
|
||||
assert.equal(today(ts), true);
|
||||
const d = dayjs(ts);
|
||||
const now = dayjs();
|
||||
assert.equal(d.year(), now.year());
|
||||
assert.equal(d.month(), now.month());
|
||||
assert.equal(d.date(), now.date());
|
||||
});
|
||||
|
||||
test('getYesterday returns the start-of-yesterday timestamp', () => {
|
||||
const ts = getYesterday();
|
||||
assert.equal(yesterday(ts), true);
|
||||
const d = dayjs(ts);
|
||||
const expected = dayjs().subtract(1, 'day');
|
||||
assert.equal(d.year(), expected.year());
|
||||
assert.equal(d.month(), expected.month());
|
||||
assert.equal(d.date(), expected.date());
|
||||
});
|
||||
Reference in New Issue
Block a user