import assert from 'node:assert/strict' import { mock, test } from 'node:test' import { Client, ChannelTypes, DiscordRESTError, Message, MessageTypes, PublicThreadChannel, ReactionType, TextChannel, User, type AnyGuildChannel, type CreateMessageOptions, type RawMessage, type RawMessageReaction, type RawUser } from 'oceanic.js' import { STARBOARD_CHANNEL, STARBOARD_GUILD, Starboard } from '../src/starboard.ts' import type { Backfill, StarboardState } from '../src/starboard-store.ts' const SOURCE = '1244870846556803100' const AUTHOR = '1244870846556803200' const ID = '1244870846556803300' function rawUser(id: string, bot = false): RawUser { return { id, bot, username: `user-${id}`, global_name: null, discriminator: '0', avatar: null, public_flags: 0 } } function reaction(name: string, normal: number, burst = 0, customID: string | null = null): RawMessageReaction { return { emoji: { name, id: customID }, count: normal + burst, count_details: { normal, burst }, burst_colors: [], me: false, me_burst: false } } function completedBackfill(): Backfill { return { phase: 'posting', candidates: {}, pending: {}, processed: 0, startedAt: '2024-01-01', completedAt: '2024-01-02', initialized: true, channels: [], jobs: [], scanned: 0, skipped: [] } } function fixture(state: StarboardState = { destinationID: STARBOARD_CHANNEL, posts: {}, backfill: completedBackfill() }) { const client = new Client() const channels = new Map() const history = new Map() const votes = new Map() const created: Array<{ id: string; channelID: string; payload: CreateMessageOptions }> = [] const edited: Array<{ id: string; payload: CreateMessageOptions }> = [] const deleted: string[] = [] let persisted = structuredClone(state) const starboard = new Starboard(client, state, (value) => { persisted = structuredClone(value) }, 0) function channel(id: string, guildID = STARBOARD_GUILD): TextChannel { const value = new TextChannel({ id, guild_id: guildID, type: ChannelTypes.GUILD_TEXT, name: id, parent_id: null, default_auto_archive_duration: 1440, last_message_id: null, last_pin_timestamp: null, rate_limit_per_user: 0, topic: null, nsfw: false, permission_overwrites: [], position: 0 }, client) channels.set(id, value) return value } function message(overrides: Partial = {}): Message { return new Message({ id: ID, channel_id: SOURCE, content: 'hello', author: rawUser(AUTHOR), attachments: [], embeds: [], edited_timestamp: null, mention_everyone: false, mention_roles: [], mentions: [], pinned: false, tts: false, timestamp: '2024-01-01T00:00:00.000Z', type: MessageTypes.DEFAULT, reactions: [reaction('⭐', 5)], ...overrides }, client) } function thread(id: string, parentID = SOURCE, archivedAt = '2024-01-01T00:00:00.000Z'): PublicThreadChannel { const value = new PublicThreadChannel({ id, guild_id: STARBOARD_GUILD, parent_id: parentID, type: ChannelTypes.PUBLIC_THREAD, name: id, owner_id: AUTHOR, last_message_id: null, message_count: 0, member_count: 0, rate_limit_per_user: 0, flags: 0, total_message_sent: 0, newly_created: false, applied_tags: [], member: { flags: 0, join_timestamp: archivedAt }, thread_metadata: { archive_timestamp: archivedAt, archived: true, auto_archive_duration: 1440, locked: false } }, client) channels.set(id, value) return value } channel(SOURCE) channel(STARBOARD_CHANNEL) mock.getter(client, 'user', () => new User(rawUser('1244870846556803900', true), client)) mock.method(client, 'getChannel', (id: string) => channels.get(id)) mock.method(client.rest.channels, 'get', async (id: string) => { const value = channels.get(id) if (value === undefined) throw restError(404, 10003) return value }) mock.method(client.rest.channels, 'getMessage', async (channelID: string, id: string) => { const value = history.get(channelID)?.find((m) => m.id === id) if (value === undefined) throw restError(404, 10008) return value }) mock.method(client.rest.channels, 'getMessages', async (channelID: string, options: { before?: string; limit: number }) => (history.get(channelID) ?? []).filter((m) => options.before === undefined || BigInt(m.id) < BigInt(options.before)) .sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1).slice(0, options.limit) ) mock.method(client.rest.channels, 'getReactions', async (_channelID: string, _id: string, emoji: string, options: { type: ReactionType }) => votes.get(`${emoji}:${options.type}`) ?? [] ) mock.method(client.rest.channels, 'createMessage', async (channelID: string, payload: CreateMessageOptions) => { const id = String(1559288341717845000n + BigInt(created.length)) created.push({ channelID, payload, id }) return message({ id, channel_id: channelID, reactions: [] }) }) mock.method(client.rest.channels, 'editMessage', async (_channelID: string, id: string, payload: CreateMessageOptions) => { edited.push({ id, payload }) return message({ id, channel_id: STARBOARD_CHANNEL }) }) mock.method(client.rest.channels, 'deleteMessage', async (_channelID: string, id: string) => { deleted.push(id) }) mock.method(client.rest.guilds, 'getChannels', async () => [...channels.values()].filter((c) => c.type === ChannelTypes.GUILD_TEXT)) mock.method(client.rest.guilds, 'getActiveThreads', async () => ({ threads: [], members: [] })) mock.method(client.rest.channels, 'getPublicArchivedThreads', async () => ({ threads: [], members: [], hasMore: false })) mock.method(client.rest.channels, 'getPrivateArchivedThreads', async () => ({ threads: [], members: [], hasMore: false })) mock.method(client.rest.channels, 'getPrivateJoinedArchivedThreads', async () => ({ threads: [], members: [], hasMore: false })) votes.set('⭐:0', Array.from({ length: 5 }, (_, i) => new User(rawUser(String(1244870846556803400n + BigInt(i))), client))) history.set(SOURCE, [message()]) return { client, starboard, state, channels, history, votes, created, edited, deleted, channel, message, thread, checkpoint: () => persisted } } function restError(status: number, code: number): DiscordRESTError { const response = new Response('', { status }) Object.defineProperty(response, 'url', { value: 'https://discord.com/api/v10/channels/test/messages/test' }) return new DiscordRESTError(response, { code, message: 'test error' }, 'GET') } test('counts unique humans across emoji and normal/super reactions, excluding author and bots', async () => { const f = fixture() const humans = f.votes.get('⭐:0')! const bot = new User(rawUser('1244870846556803999', true), f.client) const author = new User(rawUser(AUTHOR), f.client) f.votes.set('⭐:0', [...humans.slice(0, 3), bot, author]) f.votes.set('🔥:0', humans.slice(1, 4)) f.votes.set('😭:1', humans.slice(0, 4)) f.votes.set('⭐:1', [humans[0]!]) f.history.set(SOURCE, [f.message({ reactions: [reaction('⭐️', 5, 1), reaction('🔥', 3), reaction('😭', 0, 4), reaction('⭐', 100, 0, '123')] })]) // Variation selectors are preserved in API requests, not just comparison keys. f.votes.set('⭐️:0', f.votes.get('⭐:0')!) f.votes.set('⭐️:1', f.votes.get('⭐:1')!) await f.starboard.sync(SOURCE, ID) assert.equal(f.created.length, 0) f.votes.set('🔥:0', humans) await f.starboard.sync(SOURCE, ID) assert.equal(f.created.length, 1) assert.equal(f.created[0]!.payload.content, `⭐ **3** 🔥 **5** 😭 **4**\nhttps://discord.com/channels/${STARBOARD_GUILD}/${SOURCE}/${ID}`) }) test('debounces live reactions with missing REST guild IDs and handles bulk reaction removal', async (t) => { const f = fixture() t.mock.timers.enable({ apis: ['setTimeout'] }) const original = f.history.get(SOURCE)![0]! assert.equal(original.guildID, null) f.starboard.schedule(original, { name: '💀', id: null }) t.mock.timers.tick(750) await new Promise((resolve) => setImmediate(resolve)) assert.equal(f.created.length, 0) for (let i = 0; i < 10; i++) f.starboard.schedule(original, { name: '⭐', id: null }) t.mock.timers.tick(750) await new Promise((resolve) => setImmediate(resolve)) assert.equal(f.created.length, 1) f.history.set(SOURCE, [f.message({ reactions: [] })]) f.starboard.schedule(original) t.mock.timers.tick(750) await new Promise((resolve) => setImmediate(resolve)) assert.equal(f.edited.at(-1)!.payload.content, `https://discord.com/channels/${STARBOARD_GUILD}/${SOURCE}/${ID}`) }) test('tomato reactions trigger live reposts on their own or combined with other emoji', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }) for (const mixed of [false, true]) { const f = fixture() const humans = f.votes.get('⭐:0')! f.votes.set('⭐:0', humans.slice(0, 3)) f.votes.set('🍅:0', mixed ? humans.slice(2) : humans) const original = f.message({ reactions: mixed ? [reaction('⭐', 3), reaction('🍅', 3)] : [reaction('🍅', 5)] }) f.history.set(SOURCE, [original]) f.starboard.schedule(original, { name: '🍅', id: null }) t.mock.timers.tick(750) await new Promise((resolve) => setImmediate(resolve)) assert.equal(f.created.length, 1) const summary = mixed ? '⭐ **3** 🍅 **3**' : '🍅 **5**' assert.equal(f.created[0]!.payload.content, `${summary}\nhttps://discord.com/channels/${STARBOARD_GUILD}/${SOURCE}/${ID}`) assert.match(f.starboard.status(), /🍅/) } }) test('hides zero-count emoji and puts the unmasked message URL below the reactions', async () => { const f = fixture() f.history.set(SOURCE, [f.message({ reactions: [reaction('😭', 5)] })]) f.votes.set('😭:0', f.votes.get('⭐:0')!) await f.starboard.sync(SOURCE, ID) assert.equal(f.created[0]!.payload.content, `😭 **5**\nhttps://discord.com/channels/${STARBOARD_GUILD}/${SOURCE}/${ID}`) }) test('includes reply author, preview and link from the embedded referenced message', async () => { const f = fixture() const original = f.message({ id: String(BigInt(ID) - 1n), content: 'the setup', author: rawUser('1244870846556803201') }) const highlight = f.message({ type: MessageTypes.REPLY, content: 'the punchline' }) highlight.messageReference = { messageID: original.id, channelID: SOURCE, guildID: STARBOARD_GUILD } highlight.referencedMessage = original f.history.set(SOURCE, [highlight]) await f.starboard.sync(SOURCE, ID) const embed = f.created[0]!.payload.embeds![0]! assert.equal(embed.description, 'the punchline') const reply = embed.fields!.find((field) => field.name.startsWith('Replying to'))! assert.ok(reply.name.includes(original.author.username)) assert.ok(reply.value.includes('the setup')) assert.ok(reply.value.includes(`https://discord.com/channels/${STARBOARD_GUILD}/${SOURCE}/${original.id}`)) }) test('fetches an omitted reply reference and stays within the total embed text limit', async () => { const f = fixture() const original = f.message({ id: String(BigInt(ID) - 1n), content: 'context'.repeat(1000), author: { ...rawUser('1244870846556803201'), global_name: 'name'.repeat(100) } }) const highlight = f.message({ type: MessageTypes.REPLY, content: 'body'.repeat(2000), author: { ...rawUser(AUTHOR), global_name: 'name'.repeat(100) }, attachments: Array.from({ length: 10 }, (_, i) => ({ id: String(1244870846556803500n + BigInt(i)), filename: `${'file'.repeat(50)}${i}.txt`, size: 100, url: `https://cdn.discordapp.com/${'x'.repeat(200)}/${i}`, proxy_url: `https://media.discordapp.net/${i}` })) }) highlight.messageReference = { messageID: original.id, channelID: SOURCE, guildID: STARBOARD_GUILD } f.history.set(SOURCE, [highlight, original]) await f.starboard.sync(SOURCE, ID) const embed = f.created[0]!.payload.embeds![0]! assert.ok(embed.fields!.some((field) => field.value.includes('context'))) const length = (embed.author?.name?.length ?? 0) + (embed.description?.length ?? 0) + embed.fields!.reduce((sum, field) => sum + field.name.length + field.value.length, 0) assert.ok(length <= 6000) assert.ok(embed.fields!.every((field) => field.name.length <= 256 && field.value.length <= 1024)) }) test('a deleted or inaccessible reply target does not prevent reposting the reply', async () => { for (const status of [403, 404]) { const f = fixture() const highlight = f.message({ type: MessageTypes.REPLY }) highlight.messageReference = { messageID: String(BigInt(ID) - 1n), channelID: SOURCE, guildID: STARBOARD_GUILD } f.history.set(SOURCE, [highlight]) const getMessage = f.client.rest.channels.getMessage.bind(f.client.rest.channels) mock.method(f.client.rest.channels, 'getMessage', async (channelID: string, id: string) => { if (id !== ID) throw restError(status, status === 404 ? 10008 : 50001) return getMessage(channelID, id) }) await f.starboard.sync(SOURCE, ID) assert.equal(f.created.length, 1) assert.ok(f.created[0]!.payload.embeds![0]!.fields!.some((field) => field.value.includes('unavailable'))) assert.equal(f.deleted.length, 0) } }) test('scores messages with more than a page of voters', async () => { const f = fixture() f.history.set(SOURCE, [f.message({ reactions: [reaction('⭐', 150)] })]) f.votes.set('⭐:0', Array.from({ length: 150 }, (_, i) => new User(rawUser(String(1244870846556804000n + BigInt(i))), f.client) )) await f.starboard.sync(SOURCE, ID) assert.match(f.created[0]!.payload.content!, /\*\*150\*\*/) }) test('concurrent updates and a restored registry create only one repost', async () => { const f = fixture() await Promise.all(Array.from({ length: 10 }, () => f.starboard.sync(SOURCE, ID))) assert.equal(f.created.length, 1) const restored = new Starboard(f.client, f.checkpoint(), () => {}, 0) await restored.sync(SOURCE, ID) assert.equal(f.created.length, 1) }) test('retains a repost below threshold, updates edits and removes deleted originals', async () => { const f = fixture() await f.starboard.sync(SOURCE, ID) f.history.set(SOURCE, [f.message({ content: 'edited', reactions: [] })]) await f.starboard.sync(SOURCE, ID) assert.equal(f.created.length, 1) assert.equal(f.deleted.length, 0) assert.doesNotMatch(f.edited.at(-1)!.payload.content!, /⭐|🔥|😭|🍅/) assert.equal(f.edited.at(-1)!.payload.embeds![0]!.description, 'edited') await f.starboard.deleted(f.history.get(SOURCE)![0]!) assert.deepEqual(f.deleted, [f.created[0]!.id]) assert.deepEqual(f.checkpoint().posts, {}) }) test('deletion during creation is serialized and removes the newly created copy', async () => { const f = fixture() const original = f.history.get(SOURCE)![0]! let release!: () => void let entered!: () => void const blocked = new Promise((resolve) => { release = resolve }) const started = new Promise((resolve) => { entered = resolve }) const create = f.client.rest.channels.createMessage.bind(f.client.rest.channels) mock.method(f.client.rest.channels, 'createMessage', async (channelID: string, payload: CreateMessageOptions) => { entered() await blocked return create(channelID, payload) }) const posting = f.starboard.sync(SOURCE, ID) await started f.history.set(SOURCE, []) const removal = f.starboard.deleted(original) release() await Promise.all([posting, removal]) assert.equal(f.created.length, 1) assert.deepEqual(f.deleted, [f.created[0]!.id]) assert.deepEqual(f.state.posts, {}) }) test('does not mistake lost source permissions for deletion', async () => { const f = fixture() await f.starboard.sync(SOURCE, ID) mock.method(f.client.rest.channels, 'getMessage', async () => { throw restError(403, 50001) }) await assert.rejects(f.starboard.sync(SOURCE, ID)) assert.equal(f.deleted.length, 0) assert.ok(f.state.posts[ID]) mock.method(f.client.rest.channels, 'getMessage', async () => { throw restError(404, 10008) }) await f.starboard.sync(SOURCE, ID) assert.equal(f.deleted.length, 1) }) test('failed copy deletion is persisted and retried on startup', async () => { const f = fixture() await f.starboard.sync(SOURCE, ID) mock.method(f.client.rest.channels, 'deleteMessage', async () => { throw new Error('temporary failure') }) await assert.rejects(f.starboard.deleted(f.history.get(SOURCE)![0]!)) const checkpoint = f.checkpoint() assert.equal(checkpoint.posts[ID]!.deleted, true) checkpoint.backfill = completedBackfill() mock.method(f.client.rest.channels, 'deleteMessage', async (_channelID: string, id: string) => { f.deleted.push(id) }) const restored = new Starboard(f.client, checkpoint, () => {}, 0) await restored.start() assert.equal(f.deleted.length, 1) assert.deepEqual(checkpoint.posts, {}) }) test('bounds embeds, preserves attachment links and does not expose spoiler images', async () => { const f = fixture() f.history.set(SOURCE, [f.message({ content: `${'x'.repeat(5000)} @everyone`, attachments: Array.from({ length: 10 }, (_, i) => ({ id: String(1244870846556803500n + BigInt(i)), filename: `SPOILER_${'['.repeat(120)}${i}.png`, size: 100, content_type: 'image/png', url: `https://cdn.discordapp.com/${'x'.repeat(200)}/${i}`, proxy_url: `https://media.discordapp.net/${i}` })) })]) await f.starboard.sync(SOURCE, ID) const payload = f.created[0]!.payload const embed = payload.embeds![0]! assert.ok(embed.description!.length <= 4096) assert.ok(embed.fields![0]!.value.length <= 1024) assert.equal(embed.image, undefined) assert.match(embed.fields![0]!.value, /\|\|\[/) assert.deepEqual(payload.allowedMentions, { everyone: false, users: false, roles: false, repliedUser: false }) assert.match(payload.content!, new RegExp(`/channels/${STARBOARD_GUILD}/${SOURCE}/${ID}`)) }) test('ignores the destination, its threads and sources in another guild', async () => { const f = fixture() const other = '1244870846556803600' f.channel(other, '1244870846556803998') const child = f.thread('1244870846556803601', STARBOARD_CHANNEL) f.history.set(other, [f.message({ channel_id: other })]) f.history.set(child.id, [f.message({ channel_id: child.id })]) await f.starboard.sync(STARBOARD_CHANNEL, ID, { snapshot: f.message({ channel_id: STARBOARD_CHANNEL }) }) await f.starboard.sync(other, ID) await f.starboard.sync(child.id, ID) assert.equal(f.created.length, 0) }) test('excludes configured channels and their threads from live reactions and saved posting queues', async (t) => { const excluded = '1308583779157151804' const extra = '1244870846556803800' t.mock.property(process, 'env', { ...process.env, STARBOARD_EXCLUDED_CHANNEL_IDS: `${excluded}, ${extra}` }) const childID = '1244870846556803801' const f = fixture({ destinationID: STARBOARD_CHANNEL, posts: {}, backfill: { ...completedBackfill(), completedAt: undefined, candidates: { [ID]: { channelID: SOURCE }, [excluded]: { channelID: excluded }, [extra]: { channelID: extra }, [childID]: { channelID: childID } } } }) f.channel(excluded) f.channel(extra) f.thread(childID, excluded) const getMessage = f.client.rest.channels.getMessage.bind(f.client.rest.channels) t.mock.method(f.client.rest.channels, 'getMessage', async (channelID: string, id: string) => { assert.equal(channelID, SOURCE) return getMessage(channelID, id) }) for (const channelID of [excluded, extra, childID]) { f.starboard.schedule({ id: channelID, channelID, guildID: STARBOARD_GUILD }) await f.starboard.sync(channelID, channelID) } await f.starboard.start() await f.starboard.waitForBackfill() assert.equal(f.created.length, 1) assert.ok(f.created[0]!.payload.content!.endsWith(`/${SOURCE}/${ID}`)) assert.ok(f.state.backfill!.completedAt) assert.match(f.starboard.status(), new RegExp(extra)) }) test('the default exclusion applies to discovery and resumed history jobs, including uncached threads', async (t) => { const env = { ...process.env } delete env.STARBOARD_EXCLUDED_CHANNEL_IDS t.mock.property(process, 'env', env) const excluded = '1308583779157151804' const f = fixture() f.channel(excluded) const child = f.thread('1244870846556803801', excluded) t.mock.method(f.client, 'getChannel', (id: string) => id === child.id ? undefined : f.channels.get(id)) t.mock.method(f.client.rest.guilds, 'getActiveThreads', async () => ({ threads: [child], members: [] })) const getMessages = f.client.rest.channels.getMessages.bind(f.client.rest.channels) t.mock.method(f.client.rest.channels, 'getMessages', async (channelID: string, options: { before?: string; limit: number }) => { assert.equal(channelID, SOURCE) return getMessages(channelID, options) }) t.mock.method(f.client.rest.channels, 'getPublicArchivedThreads', async (channelID: string) => { assert.equal(channelID, SOURCE) return { threads: [], members: [], hasMore: false } }) f.starboard.startBackfill() await f.starboard.waitForBackfill() assert.deepEqual(f.state.backfill!.channels, [SOURCE]) f.state.backfill = { ...completedBackfill(), completedAt: undefined, phase: 'collecting', jobs: [{ kind: 'messages', channelID: excluded }, { kind: 'messages', channelID: child.id }] } await f.starboard.sync(child.id, ID) f.starboard.startBackfill() await f.starboard.waitForBackfill() assert.equal(f.state.backfill.scanned, 0) assert.equal(f.created.length, 1) }) test('an empty exclusion setting clears the default, while invalid configuration fails closed', async (t) => { t.mock.property(process, 'env', { ...process.env, STARBOARD_EXCLUDED_CHANNEL_IDS: '' }) const f = fixture() const channelID = '1308583779157151804' f.channel(channelID) f.history.set(channelID, [f.message({ channel_id: channelID })]) await f.starboard.sync(channelID, ID) assert.equal(f.created.length, 1) process.env.STARBOARD_EXCLUDED_CHANNEL_IDS = 'not-a-channel' assert.throws(() => fixture(), /STARBOARD_EXCLUDED_CHANNEL_IDS/) }) test('full backfill reaches the oldest page, resumes after failure and deduplicates rescans', async () => { const f = fixture() const history = Array.from({ length: 205 }, (_, i) => f.message({ id: String(BigInt(ID) + BigInt(i)), reactions: i === 0 || i === 204 ? [reaction('⭐', 5)] : [] })) f.history.set(SOURCE, history) const getMessages = f.client.rest.channels.getMessages.bind(f.client.rest.channels) let failed = false const visited: Array<{ channelID: string; before?: string }> = [] mock.method(f.client.rest.channels, 'getMessages', async (channelID: string, options: { before?: string; limit: number }) => { visited.push({ channelID, before: options.before }) if (options.before !== undefined && !failed) { failed = true throw new Error('connection lost') } return getMessages(channelID, options) }) const errors = mock.method(console, 'error', () => {}) assert.equal(f.starboard.startBackfill(), true) assert.equal(f.starboard.startBackfill(), false) await f.starboard.waitForBackfill() errors.mock.restore() assert.equal(f.state.backfill!.scanned, 100) assert.equal(f.created.length, 0) assert.ok(f.state.backfill!.error) const checkpoint = f.checkpoint() const restored = new Starboard(f.client, checkpoint, () => {}, 0) await restored.start() await restored.waitForBackfill() assert.equal(checkpoint.backfill!.scanned, 205) assert.ok(checkpoint.backfill!.completedAt) assert.equal(f.created.length, 2) assert.ok(f.created[0]!.payload.content!.endsWith(`/${ID}`)) assert.ok(f.created[1]!.payload.content!.endsWith(`/${BigInt(ID) + 204n}`)) assert.equal(visited.filter((v) => v.before === undefined).length, 1) assert.ok(visited.every((v) => v.channelID !== STARBOARD_CHANNEL)) restored.startBackfill() await restored.waitForBackfill() assert.equal(f.created.length, 2) }) test('discovers active and paginated archived threads, including joined-private cursor semantics', async () => { const f = fixture({ destinationID: STARBOARD_CHANNEL, posts: {} }) const active = f.thread('1244870846556803700') const archived = f.thread('1244870846556803701', SOURCE, '2023-01-01T00:00:00.000Z') const older = f.thread('1244870846556803702', SOURCE, '2022-01-01T00:00:00.000Z') for (const thread of [active, archived, older]) { f.history.set(thread.id, [f.message({ channel_id: thread.id, id: thread.id })]) } mock.method(f.client.rest.guilds, 'getActiveThreads', async () => ({ threads: [active], members: [] })) const publicCursors: Array = [] const joinedCursors: Array = [] mock.method(f.client.rest.channels, 'getPublicArchivedThreads', async (_id: string, options: { before?: string }) => { publicCursors.push(options.before) return { threads: options.before === undefined ? [archived] : [older], members: [], hasMore: options.before === undefined } }) mock.method(f.client.rest.channels, 'getPrivateJoinedArchivedThreads', async (_id: string, options: { before?: string }) => { joinedCursors.push(options.before) return { threads: options.before === undefined ? [archived] : [], members: [], hasMore: options.before === undefined } }) await f.starboard.start() await f.starboard.waitForBackfill() assert.deepEqual(publicCursors, [undefined, '2023-01-01T00:00:00.000Z']) assert.deepEqual(joinedCursors, [undefined, archived.id]) assert.equal(f.created.length, 4) assert.equal(f.state.backfill!.channels.length, 4) }) test('a destination failure pauses backfill without losing the qualifying source page', async () => { const f = fixture() const create = f.client.rest.channels.createMessage.bind(f.client.rest.channels) mock.method(f.client.rest.channels, 'createMessage', async () => { throw restError(403, 50013) }) const errors = mock.method(console, 'error', () => {}) f.starboard.startBackfill() await f.starboard.waitForBackfill() errors.mock.restore() assert.equal(f.state.backfill!.scanned, 1) assert.equal(f.state.backfill!.phase, 'posting') assert.equal(f.state.backfill!.candidates[ID]!.channelID, SOURCE) assert.ok(f.state.backfill!.error) assert.equal(f.state.backfill!.skipped.length, 0) mock.method(f.client.rest.channels, 'createMessage', create) f.starboard.startBackfill() await f.starboard.waitForBackfill() assert.equal(f.created.length, 1) assert.ok(f.state.backfill!.completedAt) }) test('collects every source before posting in global chronological order, including live candidates', async () => { const f = fixture({ destinationID: STARBOARD_CHANNEL, posts: {} }) const other = '1244870846556803801' f.channel(other) f.history.set(other, [f.message({ channel_id: other, id: String(BigInt(ID) - 1n), content: 'oldest' })]) f.history.set(SOURCE, [ f.message({ content: 'middle' }), f.message({ id: String(BigInt(ID) + 1n), content: 'newest' }) ]) let release!: () => void let entered!: () => void const blocked = new Promise((resolve) => { release = resolve }) const started = new Promise((resolve) => { entered = resolve }) mock.method(f.client.rest.channels, 'getPublicArchivedThreads', async () => { assert.equal(f.created.length, 0) entered() await blocked return { threads: [], members: [], hasMore: false } }) await f.starboard.start() await started const live = f.message({ id: String(BigInt(ID) + 2n), content: 'live' }) f.history.get(SOURCE)!.push(live) await f.starboard.sync(SOURCE, live.id) assert.equal(f.created.length, 0) release() await f.starboard.waitForBackfill() assert.deepEqual(f.created.map((c) => c.payload.embeds![0]!.description), ['oldest', 'middle', 'newest', 'live']) assert.ok(f.created.every((c) => c.channelID === STARBOARD_CHANNEL)) }) test('resumes posting in order without rescanning history, and holds new live posts until afterward', async () => { const f = fixture() const middle = String(BigInt(ID) + 1n) const newest = String(BigInt(ID) + 2n) const liveID = String(BigInt(ID) + 3n) f.history.set(SOURCE, [ f.message({ content: 'oldest' }), f.message({ id: middle, content: 'middle' }), f.message({ id: newest, content: 'newest' }) ]) const create = f.client.rest.channels.createMessage.bind(f.client.rest.channels) let failed = false mock.method(f.client.rest.channels, 'createMessage', async (channelID: string, payload: CreateMessageOptions) => { if (f.created.length === 1 && !failed) { failed = true throw new Error('connection lost') } return create(channelID, payload) }) const errors = mock.method(console, 'error', () => {}) f.starboard.startBackfill() await f.starboard.waitForBackfill() errors.mock.restore() assert.equal(f.created.length, 1) f.history.get(SOURCE)!.push(f.message({ id: liveID, content: 'live' })) await f.starboard.sync(SOURCE, liveID) assert.equal(f.created.length, 1) const restored = new Starboard(f.client, f.checkpoint(), () => {}, 0) mock.method(f.client.rest.channels, 'getMessages', async () => { throw new Error('history must not be rescanned') }) await restored.start() await restored.waitForBackfill() assert.deepEqual(f.created.map((c) => c.payload.embeds![0]!.description), ['oldest', 'middle', 'newest', 'live']) assert.ok(restored.state.backfill!.completedAt) }) test('revalidates candidate votes before posting', async () => { const f = fixture({ destinationID: STARBOARD_CHANNEL, posts: {} }) await f.starboard.sync(SOURCE, ID) assert.equal(f.created.length, 0) f.votes.get('⭐:0')!.pop() await f.starboard.start() await f.starboard.waitForBackfill() assert.equal(f.created.length, 0) assert.ok(f.state.backfill!.completedAt) }) test('changing destinations archives old mappings and rescans from the beginning without modifying old posts', async () => { const oldChannel = '1559288341717844088' const oldCopy = '1559288341717845000' const state = JSON.parse(JSON.stringify({ posts: { [ID]: { channelID: SOURCE, messageID: oldCopy, signature: 'old' } }, backfill: { startedAt: '2024-01-01', initialized: true, jobs: [], channels: [SOURCE], scanned: 4600, skipped: [] } })) as StarboardState const f = fixture(state) f.channel(oldChannel) f.history.set(oldChannel, [f.message({ id: oldCopy, channel_id: oldChannel })]) const create = f.client.rest.channels.createMessage.bind(f.client.rest.channels) mock.method(f.client.rest.channels, 'createMessage', async (channelID: string, payload: CreateMessageOptions) => { if (payload.nonce === ID) return f.message({ id: oldCopy, channel_id: oldChannel }) return create(channelID, payload) }) await f.starboard.start() await f.starboard.waitForBackfill() assert.equal(f.created.length, 1) assert.equal(f.created[0]!.channelID, STARBOARD_CHANNEL) assert.equal(f.state.destinationID, STARBOARD_CHANNEL) assert.equal(f.state.backfill!.scanned, 1) assert.equal(f.state.archivedBoards![oldChannel]!.posts[ID]!.messageID, oldCopy) assert.equal(f.deleted.length, 0) assert.equal(f.edited.length, 0) }) test('a new destination replays already posted and queued originals without rescanning history', async () => { const oldChannel = '1559380388269391892' const middleID = String(BigInt(ID) + 1n) const newestID = String(BigInt(ID) + 2n) const deletedID = String(BigInt(ID) - 1n) const oldScan: Backfill = { ...completedBackfill(), completedAt: undefined, scanned: 415615, processed: 73, candidates: { [middleID]: { channelID: SOURCE } }, pending: { [newestID]: { channelID: SOURCE } } } const oldPosts = { [ID]: { channelID: SOURCE, messageID: '1559380388269391900', signature: 'old' }, [deletedID]: { channelID: SOURCE, messageID: '1559380388269391901', signature: 'old', deleted: true } } const state: StarboardState = { destinationID: oldChannel, posts: oldPosts, backfill: oldScan } const originalState = structuredClone(state) const f = fixture(state) f.history.set(SOURCE, [ f.message({ content: 'already posted' }), f.message({ id: middleID, content: 'queued history' }), f.message({ id: newestID, content: 'queued live' }) ]) mock.method(f.client.rest.channels, 'getMessages', async () => { throw new Error('must reuse collected history') }) mock.method(f.client.rest.guilds, 'getChannels', async () => { throw new Error('must not rediscover channels') }) assert.equal(Object.keys(f.state.posts).length, 0) assert.equal(f.state.backfill!.processed, 0) assert.equal(f.state.backfill!.scanned, 415615) const restored = new Starboard(f.client, f.checkpoint(), () => {}, 0) await restored.start() await restored.waitForBackfill() assert.deepEqual(f.created.map((c) => c.payload.embeds![0]!.description), ['already posted', 'queued history', 'queued live']) assert.ok(f.created.every((c) => c.channelID === STARBOARD_CHANNEL)) assert.equal(f.edited.length, 0) assert.equal(f.deleted.length, 0) assert.deepEqual(restored.state.archivedBoards![oldChannel], { posts: originalState.posts, backfill: originalState.backfill }) assert.ok(restored.state.backfill!.completedAt) }) test('switching destination during collection retains the history cursor', async () => { const scan: Backfill = { ...completedBackfill(), completedAt: undefined, phase: 'collecting', scanned: 100, candidates: { [String(BigInt(ID) + 1n)]: { channelID: SOURCE } }, jobs: [{ kind: 'messages', channelID: SOURCE, before: String(BigInt(ID) + 1n) }] } const f = fixture({ destinationID: '1559380388269391892', posts: {}, backfill: scan }) f.history.set(SOURCE, [f.message(), f.message({ id: String(BigInt(ID) + 1n) })]) const getMessages = f.client.rest.channels.getMessages.bind(f.client.rest.channels) mock.method(f.client.rest.channels, 'getMessages', async (channelID: string, options: { before?: string; limit: number }) => { assert.equal(options.before, String(BigInt(ID) + 1n)) return getMessages(channelID, options) }) await f.starboard.start() await f.starboard.waitForBackfill() assert.equal(f.state.backfill!.scanned, 101) assert.equal(f.created.length, 2) }) test('backfill automatically retries a connection reset from its saved page', async (t) => { const f = fixture() f.history.set(SOURCE, Array.from({ length: 101 }, (_, i) => f.message({ id: String(BigInt(ID) + BigInt(i)), reactions: i === 0 ? [reaction('⭐', 5)] : [] }))) const getMessages = f.client.rest.channels.getMessages.bind(f.client.rest.channels) let firstPages = 0 let failed = false t.mock.method(console, 'warn', () => {}) t.mock.method(f.client.rest.channels, 'getMessages', async (channelID: string, options: { before?: string; limit: number }) => { if (options.before === undefined) firstPages++ else if (!failed) { failed = true throw new TypeError('fetch failed', { cause: new Error('read ECONNRESET') }) } return getMessages(channelID, options) }) f.starboard.startBackfill() await f.starboard.waitForBackfill() assert.equal(firstPages, 1) assert.equal(f.state.backfill!.scanned, 101) assert.equal(f.created.length, 1) assert.ok(f.state.backfill!.completedAt) assert.equal(f.state.backfill!.error, undefined) }) test('reports inaccessible history while continuing other channels', async () => { const f = fixture() const hidden = '1244870846556803800' f.channel(hidden) const getMessages = f.client.rest.channels.getMessages.bind(f.client.rest.channels) mock.method(f.client.rest.channels, 'getMessages', async (channelID: string, options: { before?: string; limit: number }) => { if (channelID === hidden) throw restError(403, 50001) return getMessages(channelID, options) }) const warnings = mock.method(console, 'warn', () => {}) f.starboard.startBackfill() await f.starboard.waitForBackfill() warnings.mock.restore() assert.ok(f.state.backfill!.completedAt) assert.equal(f.created.length, 1) assert.deepEqual(f.state.backfill!.skipped, [`messages:${hidden}`]) })