From 3ccca088f5cda16d4bab51c6b6012b55afad2ade Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Wed, 26 Aug 2026 11:05:01 +0100 Subject: [PATCH 1/2] feat!: parse Comms URLs with the SDK The SDK holds the shared Comms route rules, so parseCommsUrl hands the URL to it rather than walking path segments here. Both the /a/{id}/ and bare /{id}/ prefixes, the staging and local hosts, and the inbox and saved thread routes are all covered by it. A bare workspace URL names no entity, so the SDK does not recognise it; that case is still read here, and a malformed route still falls through to workspace-only rather than being misrouted as a thread, comment, or conversation ref. The search fallback link now comes from getFullCommsURL too, rather than being the one hand-built URL left in the CLI. Test IDs were short placeholders that the shared rules reject, so the URL fixtures now use real base58-encoded UUIDv7 values. BREAKING CHANGE: a Comms URL whose entity ID is not a base58-encoded UUIDv7 is no longer recognised. Comms only issues IDs in that format, so this rejects malformed links rather than passing them to the API. Co-Authored-By: Claude Opus 5 --- package-lock.json | 15 +- package.json | 2 +- src/commands/channel/members.test.ts | 27 ++- src/commands/inbox.test.ts | 2 +- src/commands/react.test.ts | 14 +- src/commands/view.test.ts | 22 +- src/index.test.ts | 4 +- src/lib/public-channels.test.ts | 12 +- src/lib/refs.test.ts | 329 +++++++++++++++++++-------- src/lib/refs.ts | 92 ++------ src/lib/search-helpers.ts | 2 +- 11 files changed, 322 insertions(+), 199 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b0107d..a7ac34e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@doist/cli-core": "0.26.2", - "@doist/comms-sdk": "2.0.2", + "@doist/comms-sdk": "3.0.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", @@ -183,11 +183,12 @@ } }, "node_modules/@doist/comms-sdk": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-2.0.2.tgz", - "integrity": "sha512-ypiEXdMOsHJMGMWxzPCH1ShOePVYEp9Q0PxiRrQMqE3DMp2Bffu3CpwxP6jxSdozvnmhvJeEc9lS3uXkKtG15g==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-3.0.0.tgz", + "integrity": "sha512-Rkwh/V8edTtY8AkiGNZt6GrS5mTiPOK3xYnX+MQT14g2nhsQPqjYhbba/QdF2QhwN4Pwm7+40wSbN93QKmXEVQ==", "license": "MIT", "dependencies": { + "@doist/sdk-kmp": "0.2.3", "camelcase": "9.0.0", "ts-custom-error": "3.3.1", "undici": "7.29.0", @@ -220,6 +221,12 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@doist/sdk-kmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@doist/sdk-kmp/-/sdk-kmp-0.2.3.tgz", + "integrity": "sha512-6V1vuu80nEhIOKDQ117kPICZJpTgYbsdIL1AiGQbAPVFkNJYLZAWG5JXqmNrVBzAwq7LF+f/47C/NZX4pzNjdw==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index b25e89c..825c219 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ ], "dependencies": { "@doist/cli-core": "0.26.2", - "@doist/comms-sdk": "2.0.2", + "@doist/comms-sdk": "3.0.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts index ed079ce..c4b55d0 100644 --- a/src/commands/channel/members.test.ts +++ b/src/commands/channel/members.test.ts @@ -28,7 +28,7 @@ const createProgram = () => createTestProgram(registerChannelCommand) function createChannel(userIds: number[], overrides: Record = {}) { return { - id: 'CH1', + id: 'CeRAj1WU3YFhsTejuePLW', name: 'General', public: true, workspaceId: 1, @@ -100,7 +100,7 @@ describe('tdc channel members list (default)', () => { await program.parseAsync(['node', 'tdc', 'channel', 'members', 'General', '--json']) const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) - expect(payload.id).toBe('CH1') + expect(payload.id).toBe('CeRAj1WU3YFhsTejuePLW') expect(payload.members).toHaveLength(3) expect(payload.members[0]).toEqual({ id: 1, name: 'Alice', email: 'a@d.com' }) expect(payload.groupsFullyInChannel).toEqual([ @@ -159,7 +159,7 @@ describe('tdc channel members add', () => { 'alice', ]) - expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [3]) + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [3]) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Added 1 user to "General" (now 3 members)') expect(output).toContain('Already members: 1') @@ -184,7 +184,7 @@ describe('tdc channel members add', () => { 'group:Frontend', ]) - expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [2, 3]) + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [2, 3]) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Expanded group "Frontend"') }) @@ -230,7 +230,7 @@ describe('tdc channel members add', () => { const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(payload).toEqual({ - id: 'CH1', + id: 'CeRAj1WU3YFhsTejuePLW', memberCount: 3, added: [3], alreadyMembers: [1], @@ -256,7 +256,7 @@ describe('tdc channel members remove', () => { 'id:9', ]) - expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [3]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [3]) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Removed 1 user from "General" (now 2 members)') expect(output).toContain('Not members: 9') @@ -317,8 +317,8 @@ describe('tdc channel members set', () => { '--apply', ]) - expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [3]) - expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [2]) + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [3]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [2]) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Set "General": +1 / -1 (now 2 members)') }) @@ -343,7 +343,12 @@ describe('tdc channel members set', () => { ]) const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) - expect(payload).toEqual({ id: 'CH1', memberCount: 2, added: [3], removed: [2] }) + expect(payload).toEqual({ + id: 'CeRAj1WU3YFhsTejuePLW', + memberCount: 2, + added: [3], + removed: [2], + }) }) it('emits JSON (not text) on dry-run --json without --apply', async () => { @@ -367,7 +372,7 @@ describe('tdc channel members set', () => { expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(payload).toEqual({ - id: 'CH1', + id: 'CeRAj1WU3YFhsTejuePLW', dryRun: true, memberCount: 2, added: [3], @@ -392,6 +397,6 @@ describe('tdc channel members set', () => { '--include-self', ]) - expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [1]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW', [1]) }) }) diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index b5b7d7d..3175fde 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -136,7 +136,7 @@ describe('inbox empty output (channel filter)', () => { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) const thread = { - id: 'TH1', + id: 'CeRAj1WU3YFhsVZGDyPr9', channelId: 'CH10', title: 't', posted: '2026-05-01T00:00:00Z', diff --git a/src/commands/react.test.ts b/src/commands/react.test.ts index 0472239..2165b28 100644 --- a/src/commands/react.test.ts +++ b/src/commands/react.test.ts @@ -37,11 +37,14 @@ describe('react refs', () => { 'tdc', 'react', 'thread', - 'https://comms.todoist.com/a/1/ch/2/t/99', + 'https://comms.todoist.com/a/1/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', '+1', ]) - expect(apiMocks.addReaction).toHaveBeenCalledWith({ threadId: '99', reaction: '👍' }) + expect(apiMocks.addReaction).toHaveBeenCalledWith({ + threadId: 'CeRAj1WU3YFhsVZGDyPr9', + reaction: '👍', + }) }) it('accepts message URLs for unreact', async () => { @@ -53,11 +56,14 @@ describe('react refs', () => { 'tdc', 'unreact', 'message', - 'https://comms.todoist.com/a/1/msg/33/m/44', + 'https://comms.todoist.com/a/1/msg/CeRAj1WU3YFhsatbAs43L/m/CeRAj1WU3YFhsbp9GT1ir', 'heart', ]) - expect(apiMocks.removeReaction).toHaveBeenCalledWith({ messageId: '44', reaction: '❤️' }) + expect(apiMocks.removeReaction).toHaveBeenCalledWith({ + messageId: 'CeRAj1WU3YFhsbp9GT1ir', + reaction: '❤️', + }) }) it('outputs JSON for react --json', async () => { diff --git a/src/commands/view.test.ts b/src/commands/view.test.ts index b1e5a1c..1b78f80 100644 --- a/src/commands/view.test.ts +++ b/src/commands/view.test.ts @@ -45,7 +45,7 @@ describe('tdc view routing', () => { 'node', 'tdc', 'view', - 'https://comms.todoist.com/a/1585/ch/100/t/200', + 'https://comms.todoist.com/a/1585/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', ]), ).rejects.toThrow('ROUTED_TO_THREAD') }) @@ -57,7 +57,7 @@ describe('tdc view routing', () => { 'node', 'tdc', 'view', - 'https://comms.todoist.com/a/1585/ch/100/t/200/c/300', + 'https://comms.todoist.com/a/1585/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', ]), ).rejects.toThrow('ROUTED_TO_THREAD') }) @@ -65,14 +65,24 @@ describe('tdc view routing', () => { it('routes conversation URL to conversation view', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tdc', 'view', 'https://comms.todoist.com/a/1585/msg/400']), + program.parseAsync([ + 'node', + 'tdc', + 'view', + 'https://comms.todoist.com/a/1585/msg/CeRAj1WU3YFhsatbAs43L', + ]), ).rejects.toThrow('ROUTED_TO_CONVERSATION') }) it('routes short conversation URL to conversation view', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tdc', 'view', 'https://comms.todoist.com/1585/msg/400']), + program.parseAsync([ + 'node', + 'tdc', + 'view', + 'https://comms.todoist.com/1585/msg/CeRAj1WU3YFhsatbAs43L', + ]), ).rejects.toThrow('ROUTED_TO_CONVERSATION') }) @@ -83,7 +93,7 @@ describe('tdc view routing', () => { 'node', 'tdc', 'view', - 'https://comms.todoist.com/a/1585/msg/400/m/500', + 'https://comms.todoist.com/a/1585/msg/CeRAj1WU3YFhsatbAs43L/m/CeRAj1WU3YFhsbp9GT1ir', ]), ).rejects.toThrow('ROUTED_TO_MSG') }) @@ -102,7 +112,7 @@ describe('tdc view routing', () => { 'node', 'tdc', 'view', - 'https://comms.todoist.com/20/inbox/t/TH1/msg/CV1', + 'https://comms.todoist.com/20/inbox/t/CeRAj1WU3YFhsVZGDyPr9/msg/CeRAj1WU3YFhsatbAs43L', ]), ).rejects.toThrow('Not a recognized Comms URL') }) diff --git a/src/index.test.ts b/src/index.test.ts index 7df9e45..981efe1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -73,7 +73,7 @@ describe('CLI entrypoint', () => { 'tdc', 'conversation', 'view', - 'https://comms.todoist.com/123/msg/ANON_MESSAGE_ID/', + 'https://comms.todoist.com/123/msg/CeRAj1WU3YFhsatbAs43L/', '--from', '2026-06-26', ]) @@ -84,7 +84,7 @@ describe('CLI entrypoint', () => { 'node', 'tdc', 'view', - 'https://comms.todoist.com/a/123/msg/456', + 'https://comms.todoist.com/a/123/msg/CeRAj1WU3YFhsatbAs43L', '--from', '2026-06-26', ]) diff --git a/src/lib/public-channels.test.ts b/src/lib/public-channels.test.ts index e9f9d5a..3222334 100644 --- a/src/lib/public-channels.test.ts +++ b/src/lib/public-channels.test.ts @@ -84,18 +84,20 @@ describe('getPublicChannelIds', () => { it('returns only public channel IDs', async () => { mockGetCommsClient.mockImplementation(() => makeMockChannels([ - { id: 'CH1', public: true }, + { id: 'CeRAj1WU3YFhsTejuePLW', public: true }, { id: 'CH2', public: false }, { id: 'CH3', public: true }, ]), ) const ids = await getPublicChannelIds(100) - expect(ids).toEqual(new Set(['CH1', 'CH3'])) + expect(ids).toEqual(new Set(['CeRAj1WU3YFhsTejuePLW', 'CH3'])) }) it('caches results per workspace', async () => { - const getChannels = vi.fn().mockResolvedValue([{ id: 'CH1', public: true }]) + const getChannels = vi + .fn() + .mockResolvedValue([{ id: 'CeRAj1WU3YFhsTejuePLW', public: true }]) mockGetCommsClient.mockResolvedValue({ channels: { getChannels }, } as unknown as Awaited>) @@ -107,7 +109,9 @@ describe('getPublicChannelIds', () => { }) it('fetches separately for different workspaces', async () => { - const getChannels = vi.fn().mockResolvedValue([{ id: 'CH1', public: true }]) + const getChannels = vi + .fn() + .mockResolvedValue([{ id: 'CeRAj1WU3YFhsTejuePLW', public: true }]) mockGetCommsClient.mockResolvedValue({ channels: { getChannels }, } as unknown as Awaited>) diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index c5ee912..47bd456 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -58,7 +58,7 @@ describe('extractId', () => { it('returns bare strings as ID', () => { expect(extractId('123')).toBe('123') - expect(extractId('7YpL3oZ4kZ9vP7Q1tR2sX3z')).toBe('7YpL3oZ4kZ9vP7Q1tR2sX3z') + expect(extractId('CeRAj1WU3YFhsTejuePLW')).toBe('CeRAj1WU3YFhsTejuePLW') }) it('trims whitespace', () => { @@ -92,7 +92,7 @@ describe('looksLikeRawId', () => { }) it('detects base58 alphanumeric strings', () => { - expect(looksLikeRawId('7YpL3oZ4kZ9vP7Q1tR2sX3z')).toBe(true) + expect(looksLikeRawId('CeRAj1WU3YFhsTejuePLW')).toBe(true) }) it('rejects plain names and spaces', () => { @@ -115,35 +115,53 @@ describe('parseCommsUrl', () => { }) it('parses channel URL with base58 id', () => { - const result = parseCommsUrl('https://comms.todoist.com/a/12345/ch/7YpL3oZ4kZ9vP7Q1tR2sX3z') - expect(result).toEqual({ workspaceId: 12345, channelId: '7YpL3oZ4kZ9vP7Q1tR2sX3z' }) + const result = parseCommsUrl('https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW') + expect(result).toEqual({ workspaceId: 12345, channelId: 'CeRAj1WU3YFhsTejuePLW' }) }) it('parses short channel URL with base58 id', () => { - const result = parseCommsUrl('https://comms.todoist.com/12345/ch/7YpL3oZ4kZ9vP7Q1tR2sX3z') - expect(result).toEqual({ workspaceId: 12345, channelId: '7YpL3oZ4kZ9vP7Q1tR2sX3z' }) + const result = parseCommsUrl('https://comms.todoist.com/12345/ch/CeRAj1WU3YFhsTejuePLW') + expect(result).toEqual({ workspaceId: 12345, channelId: 'CeRAj1WU3YFhsTejuePLW' }) }) it.each([ [ 'short thread URL', - 'https://comms.todoist.com/12345/ch/CH1/t/TH1', - { workspaceId: 12345, channelId: 'CH1', threadId: 'TH1' }, + 'https://comms.todoist.com/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', + { + workspaceId: 12345, + channelId: 'CeRAj1WU3YFhsTejuePLW', + threadId: 'CeRAj1WU3YFhsVZGDyPr9', + }, ], [ 'thread URL', - 'https://comms.todoist.com/a/12345/ch/CH1/t/TH1', - { workspaceId: 12345, channelId: 'CH1', threadId: 'TH1' }, + 'https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', + { + workspaceId: 12345, + channelId: 'CeRAj1WU3YFhsTejuePLW', + threadId: 'CeRAj1WU3YFhsVZGDyPr9', + }, ], [ 'short thread with comment URL', - 'https://comms.todoist.com/12345/ch/CH1/t/TH1/c/CM1', - { workspaceId: 12345, channelId: 'CH1', threadId: 'TH1', commentId: 'CM1' }, + 'https://comms.todoist.com/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + { + workspaceId: 12345, + channelId: 'CeRAj1WU3YFhsTejuePLW', + threadId: 'CeRAj1WU3YFhsVZGDyPr9', + commentId: 'CeRAj1WU3YFhsY6fUxMhj', + }, ], [ 'thread with comment URL', - 'https://comms.todoist.com/a/12345/ch/CH1/t/TH1/c/CM1', - { workspaceId: 12345, channelId: 'CH1', threadId: 'TH1', commentId: 'CM1' }, + 'https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + { + workspaceId: 12345, + channelId: 'CeRAj1WU3YFhsTejuePLW', + threadId: 'CeRAj1WU3YFhsVZGDyPr9', + commentId: 'CeRAj1WU3YFhsY6fUxMhj', + }, ], [ 'people URL as workspace-only', @@ -157,23 +175,31 @@ describe('parseCommsUrl', () => { it.each([ [ 'inbox thread URL', - 'https://comms.todoist.com/12345/inbox/t/TH1/', - { workspaceId: 12345, threadId: 'TH1' }, + 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/', + { workspaceId: 12345, threadId: 'CeRAj1WU3YFhsVZGDyPr9' }, ], [ 'inbox thread with comment URL', - 'https://comms.todoist.com/12345/inbox/t/TH1/c/CM1', - { workspaceId: 12345, threadId: 'TH1', commentId: 'CM1' }, + 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + { + workspaceId: 12345, + threadId: 'CeRAj1WU3YFhsVZGDyPr9', + commentId: 'CeRAj1WU3YFhsY6fUxMhj', + }, ], [ 'saved thread URL', - 'https://comms.todoist.com/12345/saved/t/TH1', - { workspaceId: 12345, threadId: 'TH1' }, + 'https://comms.todoist.com/12345/saved/t/CeRAj1WU3YFhsVZGDyPr9', + { workspaceId: 12345, threadId: 'CeRAj1WU3YFhsVZGDyPr9' }, ], [ 'saved thread with comment URL', - 'https://comms.todoist.com/12345/saved/t/TH1/c/CM1', - { workspaceId: 12345, threadId: 'TH1', commentId: 'CM1' }, + 'https://comms.todoist.com/12345/saved/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + { + workspaceId: 12345, + threadId: 'CeRAj1WU3YFhsVZGDyPr9', + commentId: 'CeRAj1WU3YFhsY6fUxMhj', + }, ], ])('parses %s', (_description, url, expected) => { expect(parseCommsUrl(url)).toEqual(expected) @@ -182,50 +208,81 @@ describe('parseCommsUrl', () => { it.each([ ['inbox root URL', 'https://comms.todoist.com/12345/inbox'], ['inbox done URL', 'https://comms.todoist.com/12345/inbox/done'], - ['inbox done thread-like URL', 'https://comms.todoist.com/12345/inbox/done/t/TH1'], + [ + 'inbox done thread-like URL', + 'https://comms.todoist.com/12345/inbox/done/t/CeRAj1WU3YFhsVZGDyPr9', + ], ['missing thread id', 'https://comms.todoist.com/12345/inbox/t'], - ['missing comment id', 'https://comms.todoist.com/12345/inbox/t/TH1/c'], - ['comment-only path', 'https://comms.todoist.com/12345/inbox/c/CM1'], - ['wrong marker after thread id', 'https://comms.todoist.com/12345/inbox/t/TH1/x/CM1'], - ['extra segment after thread id', 'https://comms.todoist.com/12345/inbox/t/TH1/extra'], + ['missing comment id', 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/c'], + ['comment-only path', 'https://comms.todoist.com/12345/inbox/c/CeRAj1WU3YFhsY6fUxMhj'], + [ + 'wrong marker after thread id', + 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/x/CeRAj1WU3YFhsY6fUxMhj', + ], + [ + 'extra segment after thread id', + 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/extra', + ], [ 'extra segment after comment id', - 'https://comms.todoist.com/12345/inbox/t/TH1/c/CM1/extra', + 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj/extra', + ], + [ + 'msg suffix after thread id', + 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/msg/CeRAj1WU3YFhsatbAs43L', + ], + [ + 'saved URL with extra segment', + 'https://comms.todoist.com/12345/saved/t/CeRAj1WU3YFhsVZGDyPr9/extra', ], - ['msg suffix after thread id', 'https://comms.todoist.com/12345/inbox/t/TH1/msg/CV1'], - ['saved URL with extra segment', 'https://comms.todoist.com/12345/saved/t/TH1/extra'], ])('leaves %s workspace-only', (_description, url) => { expect(parseCommsUrl(url)).toEqual({ workspaceId: 12345 }) }) it('parses conversation URL', () => { - const result = parseCommsUrl('https://comms.todoist.com/a/12345/msg/CV1') - expect(result).toEqual({ workspaceId: 12345, conversationId: 'CV1' }) + const result = parseCommsUrl('https://comms.todoist.com/a/12345/msg/CeRAj1WU3YFhsatbAs43L') + expect(result).toEqual({ workspaceId: 12345, conversationId: 'CeRAj1WU3YFhsatbAs43L' }) }) it('parses short conversation URL', () => { - const result = parseCommsUrl('https://comms.todoist.com/12345/msg/CV1') - expect(result).toEqual({ workspaceId: 12345, conversationId: 'CV1' }) + const result = parseCommsUrl('https://comms.todoist.com/12345/msg/CeRAj1WU3YFhsatbAs43L') + expect(result).toEqual({ workspaceId: 12345, conversationId: 'CeRAj1WU3YFhsatbAs43L' }) }) it('parses message URL', () => { - const result = parseCommsUrl('https://comms.todoist.com/a/12345/msg/CV1/m/MS1') - expect(result).toEqual({ workspaceId: 12345, conversationId: 'CV1', messageId: 'MS1' }) + const result = parseCommsUrl( + 'https://comms.todoist.com/a/12345/msg/CeRAj1WU3YFhsatbAs43L/m/CeRAj1WU3YFhsbp9GT1ir', + ) + expect(result).toEqual({ + workspaceId: 12345, + conversationId: 'CeRAj1WU3YFhsatbAs43L', + messageId: 'CeRAj1WU3YFhsbp9GT1ir', + }) }) it('parses short message URL', () => { - const result = parseCommsUrl('https://comms.todoist.com/12345/msg/CV1/m/MS1') - expect(result).toEqual({ workspaceId: 12345, conversationId: 'CV1', messageId: 'MS1' }) + const result = parseCommsUrl( + 'https://comms.todoist.com/12345/msg/CeRAj1WU3YFhsatbAs43L/m/CeRAj1WU3YFhsbp9GT1ir', + ) + expect(result).toEqual({ + workspaceId: 12345, + conversationId: 'CeRAj1WU3YFhsatbAs43L', + messageId: 'CeRAj1WU3YFhsbp9GT1ir', + }) }) it('parses staging URLs', () => { - const result = parseCommsUrl('https://comms.staging.todoist.com/12345/msg/CV1') - expect(result).toEqual({ workspaceId: 12345, conversationId: 'CV1' }) + const result = parseCommsUrl( + 'https://comms.staging.todoist.com/12345/msg/CeRAj1WU3YFhsatbAs43L', + ) + expect(result).toEqual({ workspaceId: 12345, conversationId: 'CeRAj1WU3YFhsatbAs43L' }) }) it('parses local URLs', () => { - const result = parseCommsUrl('https://comms.local.todoist.com/12345/msg/CV1') - expect(result).toEqual({ workspaceId: 12345, conversationId: 'CV1' }) + const result = parseCommsUrl( + 'https://comms.local.todoist.com/12345/msg/CeRAj1WU3YFhsatbAs43L', + ) + expect(result).toEqual({ workspaceId: 12345, conversationId: 'CeRAj1WU3YFhsatbAs43L' }) }) it('returns null for non-Comms URLs', () => { @@ -234,7 +291,7 @@ describe('parseCommsUrl', () => { }) it('returns null for unsupported protocols', () => { - expect(parseCommsUrl('ftp://comms.todoist.com/12345/msg/CV1')).toBeNull() + expect(parseCommsUrl('ftp://comms.todoist.com/12345/msg/CeRAj1WU3YFhsatbAs43L')).toBeNull() }) it('returns null for invalid URLs', () => { @@ -252,9 +309,9 @@ describe('parseRef', () => { }) it('parses base58 IDs', () => { - expect(parseRef('7YpL3oZ4kZ9vP7Q1tR2sX3z')).toEqual({ + expect(parseRef('CeRAj1WU3YFhsTejuePLW')).toEqual({ type: 'id', - id: '7YpL3oZ4kZ9vP7Q1tR2sX3z', + id: 'CeRAj1WU3YFhsTejuePLW', }) }) @@ -266,10 +323,16 @@ describe('parseRef', () => { }) it('parses URLs', () => { - const result = parseRef('https://comms.todoist.com/a/12345/ch/CH1/t/TH1') + const result = parseRef( + 'https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', + ) expect(result).toEqual({ type: 'url', - parsed: { workspaceId: 12345, channelId: 'CH1', threadId: 'TH1' }, + parsed: { + workspaceId: 12345, + channelId: 'CeRAj1WU3YFhsTejuePLW', + threadId: 'CeRAj1WU3YFhsVZGDyPr9', + }, }) }) @@ -285,9 +348,13 @@ describe('parseRef', () => { describe('getDirectChannelId', () => { it('returns ids and channel URL ids', () => { - expect(getDirectChannelId('id:CH1')).toBe('CH1') - expect(getDirectChannelId('CH1')).toBe('CH1') - expect(getDirectChannelId('https://comms.todoist.com/a/12345/ch/CH1/t/TH1')).toBe('CH1') + expect(getDirectChannelId('id:CeRAj1WU3YFhsTejuePLW')).toBe('CeRAj1WU3YFhsTejuePLW') + expect(getDirectChannelId('CeRAj1WU3YFhsTejuePLW')).toBe('CeRAj1WU3YFhsTejuePLW') + expect( + getDirectChannelId( + 'https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', + ), + ).toBe('CeRAj1WU3YFhsTejuePLW') }) it('returns null for fuzzy names', () => { @@ -295,19 +362,19 @@ describe('getDirectChannelId', () => { }) it('rejects URLs that do not identify a channel', () => { - expect(() => getDirectChannelId('https://comms.todoist.com/a/12345/msg/CV1')).toThrow( - 'Invalid channel reference', - ) + expect(() => + getDirectChannelId('https://comms.todoist.com/a/12345/msg/CeRAj1WU3YFhsatbAs43L'), + ).toThrow('Invalid channel reference') }) }) describe('resolveThreadId', () => { it('resolves id: refs', () => { - expect(resolveThreadId('id:TH1')).toBe('TH1') + expect(resolveThreadId('id:CeRAj1WU3YFhsVZGDyPr9')).toBe('CeRAj1WU3YFhsVZGDyPr9') }) it('resolves base58 ids', () => { - expect(resolveThreadId('7YpL3oZ4kZ9vP7Q1tR2sX3z')).toBe('7YpL3oZ4kZ9vP7Q1tR2sX3z') + expect(resolveThreadId('CeRAj1WU3YFhsTejuePLW')).toBe('CeRAj1WU3YFhsTejuePLW') }) it('resolves generated Comms IDs without digits', () => { @@ -315,20 +382,26 @@ describe('resolveThreadId', () => { }) it.each([ - ['thread URL', 'https://comms.todoist.com/a/12345/ch/CH1/t/TH1'], - ['thread URL with comment suffix', 'https://comms.todoist.com/a/12345/ch/CH1/t/TH1/c/CM1'], - ['inbox thread URL', 'https://comms.todoist.com/12345/inbox/t/TH1/'], + [ + 'thread URL', + 'https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', + ], + [ + 'thread URL with comment suffix', + 'https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + ], + ['inbox thread URL', 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/'], [ 'inbox thread URL with comment suffix', - 'https://comms.todoist.com/12345/inbox/t/TH1/c/CM1', + 'https://comms.todoist.com/12345/inbox/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', ], - ['saved thread URL', 'https://comms.todoist.com/12345/saved/t/TH1'], + ['saved thread URL', 'https://comms.todoist.com/12345/saved/t/CeRAj1WU3YFhsVZGDyPr9'], [ 'saved thread URL with comment suffix', - 'https://comms.todoist.com/12345/saved/t/TH1/c/CM1', + 'https://comms.todoist.com/12345/saved/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', ], ])('resolves %s', (_description, url) => { - expect(resolveThreadId(url)).toBe('TH1') + expect(resolveThreadId(url)).toBe('CeRAj1WU3YFhsVZGDyPr9') }) it('throws on invalid refs', () => { @@ -338,21 +411,27 @@ describe('resolveThreadId', () => { describe('resolveCommentId', () => { it('resolves id: refs', () => { - expect(resolveCommentId('id:CM1')).toBe('CM1') + expect(resolveCommentId('id:CeRAj1WU3YFhsY6fUxMhj')).toBe('CeRAj1WU3YFhsY6fUxMhj') }) it('resolves comment URLs', () => { - expect(resolveCommentId('https://comms.todoist.com/a/12345/ch/CH1/t/TH1/c/CM1')).toBe('CM1') + expect( + resolveCommentId( + 'https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + ), + ).toBe('CeRAj1WU3YFhsY6fUxMhj') }) }) describe('resolveChannelId', () => { it('resolves id: refs', () => { - expect(resolveChannelId('id:CH1')).toBe('CH1') + expect(resolveChannelId('id:CeRAj1WU3YFhsTejuePLW')).toBe('CeRAj1WU3YFhsTejuePLW') }) it('resolves channel URLs', () => { - expect(resolveChannelId('https://comms.todoist.com/a/12345/ch/CH1')).toBe('CH1') + expect(resolveChannelId('https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW')).toBe( + 'CeRAj1WU3YFhsTejuePLW', + ) }) }) @@ -399,30 +478,35 @@ describe('resolveChannelRef', () => { }) it('fetches channel by id: ref via getChannel', async () => { - const ch = createChannel('CH1', 'engineering') + const ch = createChannel('CeRAj1WU3YFhsTejuePLW', 'engineering') mockGetChannel.mockResolvedValue(ch) - const result = await resolveChannelRef('id:CH1', 1) + const result = await resolveChannelRef('id:CeRAj1WU3YFhsTejuePLW', 1) - expect(mockGetChannel).toHaveBeenCalledWith('CH1') + expect(mockGetChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW') expect(mockGetChannels).not.toHaveBeenCalled() expect(result).toEqual(ch) }) it('fetches channel by Comms URL via getChannel', async () => { - const ch = createChannel('CH1', 'engineering') + const ch = createChannel('CeRAj1WU3YFhsTejuePLW', 'engineering') mockGetChannel.mockResolvedValue(ch) - const result = await resolveChannelRef('https://comms.todoist.com/a/1/ch/CH1', 1) + const result = await resolveChannelRef( + 'https://comms.todoist.com/a/1/ch/CeRAj1WU3YFhsTejuePLW', + 1, + ) - expect(mockGetChannel).toHaveBeenCalledWith('CH1') + expect(mockGetChannel).toHaveBeenCalledWith('CeRAj1WU3YFhsTejuePLW') expect(result).toEqual(ch) }) it('throws CHANNEL_NOT_FOUND when id: ref resolves to a channel in another workspace', async () => { - mockGetChannel.mockResolvedValue(createChannel('CH1', 'engineering', { workspaceId: 2 })) + mockGetChannel.mockResolvedValue( + createChannel('CeRAj1WU3YFhsTejuePLW', 'engineering', { workspaceId: 2 }), + ) - await expect(resolveChannelRef('id:CH1', 1)).rejects.toHaveProperty( + await expect(resolveChannelRef('id:CeRAj1WU3YFhsTejuePLW', 1)).rejects.toHaveProperty( 'code', 'CHANNEL_NOT_FOUND', ) @@ -430,7 +514,7 @@ describe('resolveChannelRef', () => { it('throws CHANNEL_NOT_FOUND when URL workspaceId conflicts with expected workspaceId', async () => { await expect( - resolveChannelRef('https://comms.todoist.com/a/2/ch/CH1', 1), + resolveChannelRef('https://comms.todoist.com/a/2/ch/CeRAj1WU3YFhsTejuePLW', 1), ).rejects.toHaveProperty('code', 'CHANNEL_NOT_FOUND') expect(mockGetChannel).not.toHaveBeenCalled() }) @@ -526,21 +610,27 @@ describe('resolveChannelRef', () => { describe('resolveConversationId', () => { it('resolves id: refs', () => { - expect(resolveConversationId('id:CV1')).toBe('CV1') + expect(resolveConversationId('id:CeRAj1WU3YFhsatbAs43L')).toBe('CeRAj1WU3YFhsatbAs43L') }) it('resolves conversation URLs', () => { - expect(resolveConversationId('https://comms.todoist.com/a/12345/msg/CV1')).toBe('CV1') + expect( + resolveConversationId('https://comms.todoist.com/a/12345/msg/CeRAj1WU3YFhsatbAs43L'), + ).toBe('CeRAj1WU3YFhsatbAs43L') }) }) describe('resolveMessageId', () => { it('resolves id: refs', () => { - expect(resolveMessageId('id:MS1')).toBe('MS1') + expect(resolveMessageId('id:CeRAj1WU3YFhsbp9GT1ir')).toBe('CeRAj1WU3YFhsbp9GT1ir') }) it('resolves message URLs', () => { - expect(resolveMessageId('https://comms.todoist.com/a/12345/msg/CV1/m/MS1')).toBe('MS1') + expect( + resolveMessageId( + 'https://comms.todoist.com/a/12345/msg/CeRAj1WU3YFhsatbAs43L/m/CeRAj1WU3YFhsbp9GT1ir', + ), + ).toBe('CeRAj1WU3YFhsbp9GT1ir') }) }) @@ -580,15 +670,51 @@ describe('partitionNotifyIds', () => { describe('classifyCommsUrl', () => { it.each([ - ['thread URL', 'https://comms.todoist.com/a/20/ch/CH1/t/TH1', 'thread'], - ['thread+comment URL', 'https://comms.todoist.com/a/20/ch/CH1/t/TH1/c/CM1', 'comment'], - ['inbox thread URL', 'https://comms.todoist.com/20/inbox/t/TH1/', 'thread'], - ['inbox thread+comment URL', 'https://comms.todoist.com/20/inbox/t/TH1/c/CM1', 'comment'], - ['saved thread URL', 'https://comms.todoist.com/20/saved/t/TH1', 'thread'], - ['saved thread+comment URL', 'https://comms.todoist.com/20/saved/t/TH1/c/CM1', 'comment'], - ['conversation URL', 'https://comms.todoist.com/a/20/msg/CV1', 'conversation'], - ['short conversation URL', 'https://comms.todoist.com/20/msg/CV1', 'conversation'], - ['message URL', 'https://comms.todoist.com/a/20/msg/CV1/m/MS1', 'message'], + [ + 'thread URL', + 'https://comms.todoist.com/a/20/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', + 'thread', + ], + [ + 'thread+comment URL', + 'https://comms.todoist.com/a/20/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + 'comment', + ], + [ + 'inbox thread URL', + 'https://comms.todoist.com/20/inbox/t/CeRAj1WU3YFhsVZGDyPr9/', + 'thread', + ], + [ + 'inbox thread+comment URL', + 'https://comms.todoist.com/20/inbox/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + 'comment', + ], + [ + 'saved thread URL', + 'https://comms.todoist.com/20/saved/t/CeRAj1WU3YFhsVZGDyPr9', + 'thread', + ], + [ + 'saved thread+comment URL', + 'https://comms.todoist.com/20/saved/t/CeRAj1WU3YFhsVZGDyPr9/c/CeRAj1WU3YFhsY6fUxMhj', + 'comment', + ], + [ + 'conversation URL', + 'https://comms.todoist.com/a/20/msg/CeRAj1WU3YFhsatbAs43L', + 'conversation', + ], + [ + 'short conversation URL', + 'https://comms.todoist.com/20/msg/CeRAj1WU3YFhsatbAs43L', + 'conversation', + ], + [ + 'message URL', + 'https://comms.todoist.com/a/20/msg/CeRAj1WU3YFhsatbAs43L/m/CeRAj1WU3YFhsbp9GT1ir', + 'message', + ], ] as const)('classifies %s', (_description, url, entityType) => { expect(classifyCommsUrl(url)).toEqual({ entityType, url }) }) @@ -596,13 +722,28 @@ describe('classifyCommsUrl', () => { it.each([ ['inbox root URL', 'https://comms.todoist.com/20/inbox'], ['inbox done URL', 'https://comms.todoist.com/20/inbox/done'], - ['inbox done thread-like URL', 'https://comms.todoist.com/20/inbox/done/t/TH1'], - ['inbox thread with extra segment', 'https://comms.todoist.com/20/inbox/t/TH1/extra'], - ['inbox thread with msg suffix', 'https://comms.todoist.com/20/inbox/t/TH1/msg/CV1'], - ['saved thread with extra segment', 'https://comms.todoist.com/20/saved/t/TH1/extra'], + [ + 'inbox done thread-like URL', + 'https://comms.todoist.com/20/inbox/done/t/CeRAj1WU3YFhsVZGDyPr9', + ], + [ + 'inbox thread with extra segment', + 'https://comms.todoist.com/20/inbox/t/CeRAj1WU3YFhsVZGDyPr9/extra', + ], + [ + 'inbox thread with msg suffix', + 'https://comms.todoist.com/20/inbox/t/CeRAj1WU3YFhsVZGDyPr9/msg/CeRAj1WU3YFhsatbAs43L', + ], + [ + 'saved thread with extra segment', + 'https://comms.todoist.com/20/saved/t/CeRAj1WU3YFhsVZGDyPr9/extra', + ], ['workspace-only URL', 'https://comms.todoist.com/a/20'], - ['channel-only URL', 'https://comms.todoist.com/a/20/ch/CH1'], - ['malformed account URL', 'https://comms.todoist.com/a/ch/CH1/t/TH1'], + ['channel-only URL', 'https://comms.todoist.com/a/20/ch/CeRAj1WU3YFhsTejuePLW'], + [ + 'malformed account URL', + 'https://comms.todoist.com/a/ch/CeRAj1WU3YFhsTejuePLW/t/CeRAj1WU3YFhsVZGDyPr9', + ], ['non-Comms URL', 'https://google.com/a/20/t/200'], ['invalid string', 'not-a-url'], ])('returns null for %s', (_description, url) => { diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 63597aa..09df518 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -1,4 +1,4 @@ -import type { Channel, Group, Workspace } from '@doist/comms-sdk' +import { type Channel, type Group, parseCommsURL, type Workspace } from '@doist/comms-sdk' import { fetchWorkspaces, getGroup, getWorkspaceGroups, getCommsClient } from './api.js' import { CliError, type ErrorCode } from './errors.js' @@ -89,26 +89,6 @@ export interface ParsedCommsUrl { messageId?: string } -function parseInboxOrSavedThreadRoute( - routeSegments: readonly string[], -): Pick | null { - const [threadMarker, threadId, commentMarker, commentId, ...extraSegments] = routeSegments - - if (threadMarker !== 't' || !threadId || extraSegments.length > 0) { - return null - } - - if (routeSegments.length === 2) { - return { threadId } - } - - if (routeSegments.length === 4 && commentMarker === 'c' && commentId) { - return { threadId, commentId } - } - - return null -} - export function parseCommsUrl(url: string): ParsedCommsUrl | null { try { const parsed = new URL(url) @@ -119,61 +99,31 @@ export function parseCommsUrl(url: string): ParsedCommsUrl | null { return null } - const segments = parsed.pathname.split('/').filter(Boolean) - const result: ParsedCommsUrl = {} - - // Pattern: /a/{workspaceId}/... or /{workspaceId}/... - let routeStart = 0 - if (segments[0] === 'a' && /^\d+$/.test(segments[1] ?? '')) { - result.workspaceId = Number(segments[1]) - routeStart = 2 - } else if (/^\d+$/.test(segments[0] ?? '')) { - result.workspaceId = Number(segments[0]) - routeStart = 1 + // The SDK holds the shared route rules, so it reads any URL that names + // an entity. It does not recognise a bare workspace URL, and malformed + // routes fall through here as workspace-only rather than being + // misrouted as a thread, comment, or conversation ref. + const link = parseCommsURL(url) + if (link) { + const result: ParsedCommsUrl = { workspaceId: Number(link.workspaceId) } + if (link.channelId) result.channelId = link.channelId + if (link.threadId) result.threadId = link.threadId + if (link.commentId) result.commentId = link.commentId + if (link.conversationId) result.conversationId = link.conversationId + if (link.messageId) result.messageId = link.messageId + return result } - const parseRoutePairs = (start: number) => { - for (let index = start; index < segments.length - 1; index += 2) { - const value = segments[index + 1] - switch (segments[index]) { - case 'ch': - result.channelId = value - break - case 't': - result.threadId = value - break - case 'c': - result.commentId = value - break - case 'msg': - result.conversationId = value - break - case 'm': - result.messageId = value - break - } - } - } + // Pattern: /a/{workspaceId}/... or /{workspaceId}/... + const segments = parsed.pathname.split('/').filter(Boolean) + const workspaceSegment = + segments[0] === 'a' && /^\d+$/.test(segments[1] ?? '') ? segments[1] : segments[0] - const route = segments[routeStart] - if (route === 'inbox' || route === 'saved') { - // Inbox/saved routes only accept: - // t/{thread} - // t/{thread}/c/{comment} - // Other inbox/saved paths stay workspace-only so malformed URLs don't get - // misrouted as thread, comment, or conversation refs. - const threadRoute = parseInboxOrSavedThreadRoute(segments.slice(routeStart + 1)) - if (threadRoute) { - result.threadId = threadRoute.threadId - if (threadRoute.commentId) { - result.commentId = threadRoute.commentId - } - } - } else { - parseRoutePairs(routeStart) + if (workspaceSegment && /^\d+$/.test(workspaceSegment)) { + return { workspaceId: Number(workspaceSegment) } } - return Object.keys(result).length > 0 ? result : null + return null } catch { return null } diff --git a/src/lib/search-helpers.ts b/src/lib/search-helpers.ts index 1263bd2..3b32728 100644 --- a/src/lib/search-helpers.ts +++ b/src/lib/search-helpers.ts @@ -244,7 +244,7 @@ function buildSearchResultUrl( messageId: result.id, }) } - return `https://comms.todoist.com/${workspaceId}` + return getFullCommsURL({ workspaceId }) } type SearchOutputOptions = Pick From 7f6575108e3240cfdf9601f76003891e79db04df Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Wed, 26 Aug 2026 11:22:17 +0100 Subject: [PATCH 2/2] test: pin the non-base58 id contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every parse case now uses a valid base58-encoded UUIDv7, so the contract this change introduces — a well-formed path whose id is not that shape names no entity and reads as workspace-only — could regress without a failure. Covers the channel, thread and conversation routes. Co-Authored-By: Claude Opus 5 --- src/lib/refs.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index 47bd456..9d37f46 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -114,6 +114,16 @@ describe('parseCommsUrl', () => { expect(result).toEqual({ workspaceId: 12345 }) }) + it.each([ + ['channel', 'https://comms.todoist.com/a/12345/ch/CH1'], + ['thread', 'https://comms.todoist.com/a/12345/ch/CH1/t/TH1'], + ['conversation', 'https://comms.todoist.com/a/12345/msg/CV1'], + ])('reads a well-formed %s URL with a non-base58 id as workspace-only', (_name, url) => { + // Comms only issues base58-encoded UUIDv7 ids, so anything else names + // no entity and must not be passed on as a ref. + expect(parseCommsUrl(url)).toEqual({ workspaceId: 12345 }) + }) + it('parses channel URL with base58 id', () => { const result = parseCommsUrl('https://comms.todoist.com/a/12345/ch/CeRAj1WU3YFhsTejuePLW') expect(result).toEqual({ workspaceId: 12345, channelId: 'CeRAj1WU3YFhsTejuePLW' })