- shared.ts: parseJid now recognizes raw Baileys WhatsApp JIDs (`<id>@s.whatsapp.net`, `@g.us`, etc.); v2PlatformId returns the raw JID for whatsapp to match what the runtime adapter emits. Without this, every WhatsApp group in a v1 install was silently skipped. - discord-resolver.ts: new helper that uses DISCORD_BOT_TOKEN to look up channelId → guildId via the Discord API, since v1 stored only the channel id but v2 needs `discord:<guildId>:<channelId>`. Best-effort: on missing/invalid token or network error, returns empty resolver and the affected groups are skipped with the reason surfaced per channel. - db.ts, tasks.ts: route Discord groups through the resolver; other channels go through v2PlatformId unchanged. Resolver only built when at least one Discord group exists, so non-Discord installs incur no network. - db.ts: when every v1 group is skipped, exit non-zero with a FAIL line instead of `OK:groups=N,...,skipped=N`, so the wrapper doesn't hide total failure under a successful-looking summary. - migrate-v2.sh: run_step now surfaces ERROR: lines from successful steps (with count + first 3 + raw log path); phase 2c install loop populates STEP_RESULTS so install failures show in handoff.json instead of silently passing. - sessions.ts: copyTree skips dangling symlinks (e.g. v1's `.claude/debug/latest`) instead of crashing the entire step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { buildDiscordResolver } from './discord-resolver.js';
|
|
|
|
function mockFetch(handlers: Record<string, unknown>): typeof fetch {
|
|
return vi.fn(async (input: string | URL | Request) => {
|
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
const match = Object.keys(handlers).find((k) => url.startsWith(k));
|
|
if (!match) throw new Error(`unexpected fetch: ${url}`);
|
|
const body = handlers[match];
|
|
if (body instanceof Error) throw body;
|
|
if (typeof body === 'object' && body !== null && 'status' in body && (body as { status?: number }).status) {
|
|
const r = body as { status: number; statusText?: string; body?: string };
|
|
return new Response(r.body ?? '', { status: r.status, statusText: r.statusText ?? '' });
|
|
}
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
describe('buildDiscordResolver', () => {
|
|
it('returns empty resolver when token is missing', async () => {
|
|
const r = await buildDiscordResolver('');
|
|
expect(r.stats()).toMatchObject({ guilds: 0, channels: 0 });
|
|
expect(r.stats().reason).toMatch(/no DISCORD_BOT_TOKEN/);
|
|
expect(r.resolve('any')).toBeNull();
|
|
});
|
|
|
|
it('resolves channels to guild-prefixed platform ids', async () => {
|
|
const fetchImpl = mockFetch({
|
|
'https://discord.com/api/v10/users/@me/guilds': [
|
|
{ id: 'g1', name: 'Guild 1' },
|
|
{ id: 'g2', name: 'Guild 2' },
|
|
],
|
|
'https://discord.com/api/v10/guilds/g1/channels': [
|
|
{ id: 'c1' },
|
|
{ id: 'c2' },
|
|
],
|
|
'https://discord.com/api/v10/guilds/g2/channels': [
|
|
{ id: 'c3' },
|
|
],
|
|
});
|
|
|
|
const r = await buildDiscordResolver('valid-token', fetchImpl);
|
|
|
|
expect(r.stats()).toEqual({ guilds: 2, channels: 3 });
|
|
expect(r.resolve('c1')).toBe('discord:g1:c1');
|
|
expect(r.resolve('c2')).toBe('discord:g1:c2');
|
|
expect(r.resolve('c3')).toBe('discord:g2:c3');
|
|
expect(r.resolve('cX')).toBeNull();
|
|
});
|
|
|
|
it('returns disabled resolver on 401', async () => {
|
|
const fetchImpl = mockFetch({
|
|
'https://discord.com/api/v10/users/@me/guilds': {
|
|
status: 401,
|
|
statusText: 'Unauthorized',
|
|
body: '{"message":"401: Unauthorized","code":0}',
|
|
},
|
|
});
|
|
|
|
const r = await buildDiscordResolver('bad-token', fetchImpl);
|
|
expect(r.stats().guilds).toBe(0);
|
|
expect(r.stats().reason).toMatch(/401/);
|
|
expect(r.resolve('c1')).toBeNull();
|
|
});
|
|
|
|
it('keeps partial results when one guild lookup fails', async () => {
|
|
const fetchImpl = mockFetch({
|
|
'https://discord.com/api/v10/users/@me/guilds': [
|
|
{ id: 'g1', name: 'Good Guild' },
|
|
{ id: 'g2', name: 'Bad Guild' },
|
|
],
|
|
'https://discord.com/api/v10/guilds/g1/channels': [{ id: 'c1' }],
|
|
'https://discord.com/api/v10/guilds/g2/channels': {
|
|
status: 403,
|
|
statusText: 'Forbidden',
|
|
body: '{}',
|
|
},
|
|
});
|
|
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const r = await buildDiscordResolver('valid-token', fetchImpl);
|
|
errSpy.mockRestore();
|
|
|
|
expect(r.resolve('c1')).toBe('discord:g1:c1');
|
|
expect(r.stats().guilds).toBe(2);
|
|
expect(r.stats().channels).toBe(1);
|
|
});
|
|
|
|
it('paginates the guild list', async () => {
|
|
// First page: 200 guilds (g0..g199); second page: 1 guild (g200); third call would not happen.
|
|
const page1 = Array.from({ length: 200 }, (_, i) => ({ id: `g${i}`, name: `G${i}` }));
|
|
const page2 = [{ id: 'g200', name: 'G200' }];
|
|
let call = 0;
|
|
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
|
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes('/users/@me/guilds')) {
|
|
call++;
|
|
const body = call === 1 ? page1 : page2;
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}
|
|
// Every guild has one channel named after itself
|
|
const m = /\/guilds\/([^/]+)\/channels/.exec(url);
|
|
const gid = m ? m[1] : '';
|
|
return new Response(JSON.stringify([{ id: `c-${gid}` }]), { status: 200 });
|
|
}) as unknown as typeof fetch;
|
|
|
|
const r = await buildDiscordResolver('valid-token', fetchImpl);
|
|
|
|
expect(r.stats().guilds).toBe(201);
|
|
expect(r.stats().channels).toBe(201);
|
|
expect(r.resolve('c-g0')).toBe('discord:g0:c-g0');
|
|
expect(r.resolve('c-g200')).toBe('discord:g200:c-g200');
|
|
});
|
|
});
|