From ae1d30bc5aa897149bac7062b8a2fc0975efdc2b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 10:27:57 -0400 Subject: [PATCH] test: add suites for time, matrix, mimeTypes, and search filters (+47 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../message-search/useMessageSearch.test.ts | 74 ++++++++ src/app/utils/matrix.test.ts | 132 ++++++++++++++ src/app/utils/mimeTypes.test.ts | 60 ++++++ src/app/utils/time.test.ts | 171 ++++++++++++++++++ 4 files changed, 437 insertions(+) create mode 100644 src/app/features/message-search/useMessageSearch.test.ts create mode 100644 src/app/utils/matrix.test.ts create mode 100644 src/app/utils/mimeTypes.test.ts create mode 100644 src/app/utils/time.test.ts diff --git a/src/app/features/message-search/useMessageSearch.test.ts b/src/app/features/message-search/useMessageSearch.test.ts new file mode 100644 index 000000000..c6d16d47b --- /dev/null +++ b/src/app/features/message-search/useMessageSearch.test.ts @@ -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[] }[] +): 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'], + ); +}); diff --git a/src/app/utils/matrix.test.ts b/src/app/utils/matrix.test.ts new file mode 100644 index 000000000..fc3895890 --- /dev/null +++ b/src/app/utils/matrix.test.ts @@ -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); +}); diff --git a/src/app/utils/mimeTypes.test.ts b/src/app/utils/mimeTypes.test.ts new file mode 100644 index 000000000..92b451389 --- /dev/null +++ b/src/app/utils/mimeTypes.test.ts @@ -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'); +}); diff --git a/src/app/utils/time.test.ts b/src/app/utils/time.test.ts new file mode 100644 index 000000000..054de86c7 --- /dev/null +++ b/src/app/utils/time.test.ts @@ -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()); +});