fix(api)!: correct pagination and locking, drop toggle routes (#2505)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:31:13 +02:00
1 parent 632f4c7b6c
commit d07f520b13
10 files changed
+302 -626

No files matched your search

@@ -171,6 +171,10 @@ export function MessageInteractionController(app: HonoApp) {
const result = await ctx
.get('channelService')
.interactions.getUsersForReaction({userId, channelId, messageId, emoji, limit, after: afterUserId});
ctx.header('X-Has-More', result.has_more ? 'true' : 'false');
if (result.next_after !== null) {
ctx.header('X-Next-After', result.next_after);
}
return ctx.json(result.users, 200);
},
);
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake';
import {describe, expect, it} from 'vitest';
import type {ChannelID, MessageID} from '../../../BrandedTypes';
import {createChannelID, createGuildID, createMessageID, createUserID} from '../../../BrandedTypes';
import type {GuildAuditLogService} from '../../../guild/GuildAuditLogService';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
import {Message} from '../../../models/Message';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
import {MessagePinService} from './MessagePinService';
const CHANNEL_ID = createChannelID(10n);
const GUILD_ID = createGuildID(20n);
const VIEWER_ID = createUserID(7n);
const CUTOFF = new Date('2026-01-10T00:00:00.000Z');
const CUTOFF_ISO: string | null = CUTOFF.toISOString();
const HIDDEN_CREATED_AT = new Date('2026-01-01T00:00:00.000Z');
const VISIBLE_CREATED_AT = new Date('2026-01-20T00:00:00.000Z');
const PIN_EPOCH = new Date('2026-02-01T00:00:00.000Z');
function makeMessageId(timestamp: Date, sequence: number): MessageID {
return createMessageID(createSnowflakeFromTimestamp(timestamp.getTime()) + BigInt(sequence));
}
function makePin(createdAt: Date, sequence: number, pinnedAtOffsetMs: number): Message {
return new Message({
channel_id: CHANNEL_ID,
bucket: 0,
message_id: makeMessageId(createdAt, sequence),
author_id: null,
type: MessageTypes.DEFAULT,
webhook_id: null,
webhook_name: null,
webhook_avatar_hash: null,
content: '',
edited_timestamp: null,
pinned_timestamp: new Date(PIN_EPOCH.getTime() + pinnedAtOffsetMs),
flags: 0,
mention_everyone: false,
mention_users: null,
mention_roles: null,
mention_channels: null,
attachments: null,
embeds: null,
sticker_items: null,
message_reference: null,
message_snapshots: null,
call: null,
has_reaction: null,
version: 1,
});
}
function createPinService({canReadHistory, pins}: {canReadHistory: boolean; pins: Array<Message>}) {
const calls: Array<{before: Date; limit: number}> = [];
const channelRepository = {
messageInteractions: {
listChannelPins: async (_channelId: ChannelID, before: Date, limit: number) => {
calls.push({before, limit});
return pins
.filter((pin) => pin.pinnedTimestamp!.getTime() < before.getTime())
.sort((a, b) => b.pinnedTimestamp!.getTime() - a.pinnedTimestamp!.getTime())
.slice(0, limit);
},
},
} as unknown as IChannelRepositoryAggregate;
const channel = {id: CHANNEL_ID, type: ChannelTypes.GUILD_TEXT, guildId: GUILD_ID};
const authChannel: AuthenticatedChannel = {
channel: channel as AuthenticatedChannel['channel'],
guild: {message_history_cutoff: CUTOFF_ISO} as AuthenticatedChannel['guild'],
member: null,
hasPermission: async () => canReadHistory,
checkPermission: async () => {},
};
const service = new MessagePinService(
{} as unknown as IGatewayService,
channelRepository,
{} as unknown as ISnowflakeService,
{} as unknown as MessagePersistenceService,
{} as unknown as GuildAuditLogService,
);
return {
authChannel,
calls,
listPins: (limit: number) =>
service.getChannelPins({
authChannel,
userId: VIEWER_ID,
requestCache: {} as unknown as RequestCache,
limit,
}),
};
}
describe('MessagePinService.getChannelPins', () => {
it('walks past a full page of pins hidden by the history cutoff', async () => {
const hidden = Array.from({length: 12}, (_value, index) => makePin(HIDDEN_CREATED_AT, index, 1_000 + index));
const visible = Array.from({length: 5}, (_value, index) => makePin(VISIBLE_CREATED_AT, index, index));
const {calls, listPins} = createPinService({canReadHistory: false, pins: [...hidden, ...visible]});
const page = await listPins(10);
expect(page.items.map((item) => item.message.id)).toEqual(
[...visible].reverse().map((message) => message.id.toString()),
);
expect(page.has_more).toBe(false);
expect(calls).toHaveLength(2);
});
it('reports has_more when visible pins behind the hidden run outnumber the page', async () => {
const hidden = Array.from({length: 12}, (_value, index) => makePin(HIDDEN_CREATED_AT, index, 1_000 + index));
const visible = Array.from({length: 11}, (_value, index) => makePin(VISIBLE_CREATED_AT, index, index));
const {listPins} = createPinService({canReadHistory: false, pins: [...hidden, ...visible]});
const page = await listPins(10);
expect(page.items).toHaveLength(10);
expect(page.has_more).toBe(true);
expect(page.items.map((item) => item.message.id)).toEqual(
[...visible]
.reverse()
.slice(0, 10)
.map((message) => message.id.toString()),
);
});
it('reads the pin index once for a caller that can read message history', async () => {
const pins = Array.from({length: 11}, (_value, index) => makePin(HIDDEN_CREATED_AT, index, index));
const {calls, listPins} = createPinService({canReadHistory: true, pins});
const page = await listPins(10);
expect(page.items).toHaveLength(10);
expect(page.has_more).toBe(true);
expect(calls).toHaveLength(1);
});
});
@@ -79,21 +79,31 @@ export class MessagePinService extends MessageInteractionBase {
}
}
const pageSize = Math.min(limit ?? 50, 50);
const effectiveBefore = beforeTimestamp ?? new Date();
const messages = await this.channelRepository.messageInteractions.listChannelPins(
channel.id,
effectiveBefore,
pageSize + 1,
);
const sorted = messages.sort((a, b) => (b.pinnedTimestamp?.getTime() ?? 0) - (a.pinnedTimestamp?.getTime() ?? 0));
let filtered = sorted;
if (!hasReadHistory) {
const cutoff = authChannel.guild!.message_history_cutoff!;
const cutoffTimestamp = new Date(cutoff).getTime();
filtered = sorted.filter((message) => snowflakeToDate(message.id).getTime() >= cutoffTimestamp);
const cutoffTimestamp = hasReadHistory ? null : new Date(authChannel.guild!.message_history_cutoff!).getTime();
const filtered: Array<Message> = [];
let before = beforeTimestamp ?? new Date();
let exhausted = false;
while (filtered.length <= pageSize && !exhausted) {
const messages = await this.channelRepository.messageInteractions.listChannelPins(
channel.id,
before,
pageSize + 1,
);
const sorted = messages.sort((a, b) => (b.pinnedTimestamp?.getTime() ?? 0) - (a.pinnedTimestamp?.getTime() ?? 0));
exhausted = messages.length <= pageSize;
for (const message of sorted) {
if (cutoffTimestamp === null || snowflakeToDate(message.id).getTime() >= cutoffTimestamp) {
filtered.push(message);
}
}
const last = sorted[sorted.length - 1];
if (!last?.pinnedTimestamp) {
break;
}
before = last.pinnedTimestamp;
}
const hasMore = filtered.length > pageSize;
const trimmed = hasMore ? filtered.slice(0, pageSize) : filtered;
const trimmed = filtered.slice(0, pageSize);
const access = {
sourceGuildId: channel.guildId,
messageHistoryCutoff: hasReadHistory ? null : (authChannel.guild?.message_history_cutoff ?? null),
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {sendChannelMessage, setupTestGuildWithMembers} from './ChannelTestUtils';
const EMOJI = encodeURIComponent('👍');
interface ReactionUsersPage {
items: Array<{id: string}>;
has_more: boolean;
next_after: string | null;
}
describe('Reaction users pagination', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function setupReactedMessage(): Promise<{token: string; channelId: string; messageId: string}> {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const message = await sendChannelMessage(harness, owner.token, systemChannel.id, 'react to me');
for (const account of [owner, ...members]) {
await createBuilder(harness, account.token)
.put(`/channels/${systemChannel.id}/messages/${message.id}/reactions/${EMOJI}/@me`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
return {token: owner.token, channelId: systemChannel.id, messageId: message.id};
}
it('carries the pagination signal of the page in headers', async () => {
const {token, channelId, messageId} = await setupReactedMessage();
const legacy = await createBuilder<Array<{id: string}>>(harness, token)
.get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}?limit=2`)
.executeWithResponse();
const page = await createBuilder<ReactionUsersPage>(harness, token)
.get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}/users?limit=2`)
.execute();
expect(legacy.json.map((user) => user.id)).toEqual(page.items.map((user) => user.id));
expect(page.has_more).toBe(true);
expect(legacy.response.headers.get('X-Has-More')).toBe('true');
expect(legacy.response.headers.get('X-Next-After')).toBe(page.next_after);
});
it('omits the cursor header on the final page', async () => {
const {token, channelId, messageId} = await setupReactedMessage();
const legacy = await createBuilder<Array<{id: string}>>(harness, token)
.get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}?limit=3`)
.executeWithResponse();
expect(legacy.json).toHaveLength(3);
expect(legacy.response.headers.get('X-Has-More')).toBe('false');
expect(legacy.response.headers.get('X-Next-After')).toBeNull();
});
});