Skip to content

Commit 2771deb

Browse files
committed
test(mock): moar integration tests
1 parent 5626f0a commit 2771deb

File tree

21 files changed

+1223
-54
lines changed

21 files changed

+1223
-54
lines changed
Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,65 @@
11
import type { Config } from 'jest'
22

3+
// Shared ts-jest config that overrides the bundler moduleResolution
4+
const tsJestConfig = {
5+
useESM: true,
6+
isolatedModules: true,
7+
tsconfig: {
8+
module: 'ESNext',
9+
target: 'ES2022',
10+
moduleResolution: 'node',
11+
skipLibCheck: true,
12+
esModuleInterop: true
13+
}
14+
}
15+
316
const config: Config = {
417
testEnvironment: 'node',
518
extensionsToTreatAsEsm: ['.ts'],
619
testMatch: ['**/?(*.)+(test).[tj]s?(x)'],
720
transform: {
8-
'^.+\\.ts$': [
9-
'ts-jest',
10-
{ useESM: true, isolatedModules: true, tsconfig: { module: 'ESNext', target: 'ES2022', skipLibCheck: true } }
11-
]
21+
'^.+\\.ts$': ['ts-jest', tsJestConfig]
1222
},
1323
moduleNameMapper: {
1424
'^(\\.{1,2}/.*)\\.js$': '$1',
1525
'^@/(.*)\\.js$': '<rootDir>/src/$1',
16-
'^@/(.*)$': '<rootDir>/src/$1'
26+
'^@/(.*)$': '<rootDir>/src/$1',
27+
'^robo\\.js$': '<rootDir>/__mocks__/robo.js.ts'
1728
},
1829
modulePaths: ['<rootDir>/.robo/build', '<rootDir>/__tests__'],
1930
transformIgnorePatterns: ['/node_modules/'],
20-
verbose: true
31+
verbose: true,
32+
// Integration tests run sequentially to share the server instance
33+
projects: [
34+
{
35+
displayName: 'unit',
36+
testMatch: ['<rootDir>/__tests__/**/*.test.ts', '!<rootDir>/__tests__/integration/**'],
37+
transform: {
38+
'^.+\\.ts$': ['ts-jest', tsJestConfig]
39+
},
40+
moduleNameMapper: {
41+
'^(\\.{1,2}/.*)\\.js$': '$1',
42+
'^@/(.*)\\.js$': '<rootDir>/src/$1',
43+
'^@/(.*)$': '<rootDir>/src/$1',
44+
'^robo\\.js$': '<rootDir>/__mocks__/robo.js.ts'
45+
}
46+
},
47+
{
48+
displayName: 'integration',
49+
testMatch: ['<rootDir>/__tests__/integration/**/*.test.ts'],
50+
transform: {
51+
'^.+\\.ts$': ['ts-jest', tsJestConfig]
52+
},
53+
moduleNameMapper: {
54+
'^(\\.{1,2}/.*)\\.js$': '$1',
55+
'^@/(.*)\\.js$': '<rootDir>/src/$1',
56+
'^@/(.*)$': '<rootDir>/src/$1',
57+
'^robo\\.js$': '<rootDir>/__mocks__/robo.js.ts'
58+
},
59+
globalSetup: '<rootDir>/__tests__/integration/global-setup.js',
60+
globalTeardown: '<rootDir>/__tests__/integration/global-teardown.js'
61+
}
62+
]
2163
}
2264

2365
export default config

packages/@robojs/mock/src/api/control/sessions/[id]/dispatch.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export default async (request: RoboRequest) => {
8282
// Handle MESSAGE_CREATE specially
8383
if (body.event === 'MESSAGE_CREATE') {
8484
const data = body.data as {
85+
id?: string
8586
channel_id?: string
8687
content?: string
8788
author?: {
@@ -103,6 +104,7 @@ export default async (request: RoboRequest) => {
103104

104105
try {
105106
const message = await session.dispatchMessage({
107+
id: data.id,
106108
channelId: data.channel_id,
107109
content: data.content,
108110
author: data.author,

packages/@robojs/mock/src/api/v10/channels/[id].ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { sessionManager } from '../../../core/manager.js'
33
import { parseMockToken } from '../../../utils/id.js'
44
import { mockThreadToAPIChannel, mockChannelToAPIChannel } from '../../../discord/payloads.js'
55
import { enforcePermissions } from '../../../utils/permission-check.js'
6+
import { getGatewayServer } from '../../../core/gateway.js'
67

78
/**
89
* Channel endpoint - handles GET, PATCH and DELETE for channels (including threads)
@@ -125,11 +126,16 @@ export default async (request: RoboRequest) => {
125126
)
126127
}
127128

128-
// Return the deleted channel object (no member field on delete since thread is gone)
129+
// Dispatch CHANNEL_DELETE event
129130
if (isThread) {
130-
return mockThreadToAPIChannel(channel as any)
131+
const apiChannel = mockThreadToAPIChannel(channel as any)
132+
getGatewayServer().dispatchToSession(session.id, 'CHANNEL_DELETE', apiChannel, channel.guildId)
133+
return apiChannel
134+
} else {
135+
const apiChannel = mockChannelToAPIChannel(channel)
136+
getGatewayServer().dispatchToSession(session.id, 'CHANNEL_DELETE', apiChannel, channel.guildId)
137+
return apiChannel
131138
}
132-
return mockChannelToAPIChannel(channel)
133139
}
134140

135141
// 5b. PATCH - Modify channel/thread
@@ -145,6 +151,9 @@ export default async (request: RoboRequest) => {
145151
nsfw?: boolean
146152
position?: number
147153
parent_id?: string | null
154+
permission_overwrites?: Array<{ id: string; type: number; allow: string; deny: string }>
155+
bitrate?: number
156+
user_limit?: number
148157
}
149158

150159
try {
@@ -187,15 +196,45 @@ export default async (request: RoboRequest) => {
187196
}
188197
)
189198

190-
// Include member field if bot is a member of the thread
199+
// Dispatch THREAD_UPDATE event
191200
const botMember = session.state.getThreadMember(channelId, session.state.botUser.id)
192-
return mockThreadToAPIChannel(thread, botMember ?? undefined)
201+
const apiChannel = mockThreadToAPIChannel(thread, botMember ?? undefined)
202+
getGatewayServer().dispatchToSession(session.id, 'THREAD_UPDATE', apiChannel, thread.guildId)
203+
204+
return apiChannel
193205
} else {
194206
// Update regular channel (basic implementation)
195207
if (body.name !== undefined) {
196208
channel.name = body.name
197209
}
198210

211+
// Update additional channel properties
212+
if (body.topic !== undefined && channel.type === 0) {
213+
channel.topic = body.topic
214+
}
215+
if (body.nsfw !== undefined) {
216+
channel.nsfw = body.nsfw
217+
}
218+
if (body.rate_limit_per_user !== undefined) {
219+
channel.rateLimitPerUser = body.rate_limit_per_user
220+
}
221+
if (body.bitrate !== undefined && channel.type === 2) {
222+
channel.bitrate = body.bitrate
223+
}
224+
if (body.user_limit !== undefined && channel.type === 2) {
225+
channel.userLimit = body.user_limit
226+
}
227+
228+
// Handle permission_overwrites (for lockPermissions and direct updates)
229+
if (body.permission_overwrites !== undefined) {
230+
channel.permissionOverwrites = body.permission_overwrites.map((ow) => ({
231+
id: ow.id,
232+
type: ow.type,
233+
allow: ow.allow,
234+
deny: ow.deny
235+
}))
236+
}
237+
199238
// Record action
200239
session.recordAction(
201240
'channel_updated',
@@ -209,6 +248,10 @@ export default async (request: RoboRequest) => {
209248
}
210249
)
211250

212-
return mockChannelToAPIChannel(channel)
251+
// Dispatch CHANNEL_UPDATE event
252+
const apiChannel = mockChannelToAPIChannel(channel)
253+
getGatewayServer().dispatchToSession(session.id, 'CHANNEL_UPDATE', apiChannel, channel.guildId)
254+
255+
return apiChannel
213256
}
214257
}

packages/@robojs/mock/src/api/v10/channels/[id]/messages.ts

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,25 @@ import type { MockAttachment, AttachmentPayload, StoredAttachment } from '../../
99
import { MessageFlags, createComponentValidationError, createV2ConflictError } from '../../../../types/index.js'
1010
import { validateComponentsV2 } from '../../../../session/state.js'
1111
import { enforcePermissions } from '../../../../utils/permission-check.js'
12+
import { getGatewayServer } from '../../../../core/gateway.js'
1213

1314
// Default port for CDN URLs (can be overridden via environment)
1415
const CDN_BASE_URL = process.env.MOCK_CDN_URL || 'http://localhost:53596'
1516

1617
/**
18+
* GET /api/v10/channels/:id/messages - List messages in a channel
1719
* POST /api/v10/channels/:id/messages - Create a message in a channel
1820
*
19-
* This endpoint captures messages sent by the bot via REST API.
20-
* It creates the message in session state, records it as an action,
21-
* and returns the created message in Discord's APIMessage format.
21+
* GET: Returns an array of messages with optional limit, before, after, around
22+
* POST: Creates a message and returns APIMessage object
2223
*
2324
* Supports both:
2425
* - JSON body: { content, embeds, components, tts, message_reference }
2526
* - Multipart: payload_json + files[0], files[1], etc.
26-
*
27-
* Response: APIMessage object
2827
*/
2928
export default async (request: RoboRequest) => {
30-
// 1. Validate POST method
31-
if (request.method !== 'POST') {
29+
// 1. Validate method
30+
if (request.method !== 'GET' && request.method !== 'POST') {
3231
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
3332
status: 405,
3433
headers: { 'Content-Type': 'application/json' }
@@ -66,6 +65,70 @@ export default async (request: RoboRequest) => {
6665
})
6766
}
6867

68+
// GET - List messages
69+
if (request.method === 'GET') {
70+
// Check permissions
71+
const permError = enforcePermissions(session, 'GET', `/channels/${channelId}/messages`, channelId)
72+
if (permError) return permError
73+
74+
// Parse query parameters
75+
const url = new URL(request.url)
76+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100)
77+
const before = url.searchParams.get('before')
78+
const after = url.searchParams.get('after')
79+
const around = url.searchParams.get('around')
80+
81+
// Get messages for channel
82+
let messages = session.state.getMessagesForChannel(channelId)
83+
84+
// Sort by timestamp descending (newest first)
85+
messages.sort((a, b) => {
86+
const timeA = new Date(a.timestamp).getTime()
87+
const timeB = new Date(b.timestamp).getTime()
88+
return timeB - timeA
89+
})
90+
91+
// Apply pagination
92+
if (around) {
93+
// Find message and return messages around it
94+
const aroundIndex = messages.findIndex((m) => m.id === around)
95+
if (aroundIndex >= 0) {
96+
const start = Math.max(0, aroundIndex - Math.floor(limit / 2))
97+
messages = messages.slice(start, start + limit)
98+
} else {
99+
messages = messages.slice(0, limit)
100+
}
101+
} else if (before) {
102+
// Get messages before this ID
103+
const beforeIndex = messages.findIndex((m) => m.id === before)
104+
if (beforeIndex >= 0) {
105+
messages = messages.slice(beforeIndex + 1, beforeIndex + 1 + limit)
106+
} else {
107+
messages = messages.slice(0, limit)
108+
}
109+
} else if (after) {
110+
// Get messages after this ID
111+
const afterIndex = messages.findIndex((m) => m.id === after)
112+
if (afterIndex >= 0) {
113+
messages = messages.slice(0, afterIndex).slice(-limit)
114+
} else {
115+
messages = messages.slice(0, limit)
116+
}
117+
} else {
118+
// Just limit
119+
messages = messages.slice(0, limit)
120+
}
121+
122+
// Convert to API format
123+
const apiMessages = messages.map((msg) => {
124+
const author = session.state.getUser(msg.authorId) || session.state.botUser
125+
return mockMessageToAPIMessage(msg, author)
126+
})
127+
128+
return apiMessages
129+
}
130+
131+
// POST - Create message
69132
// 4b. Check permissions (Phase 4L-Extended)
70133
const permError = enforcePermissions(
71134
session,
@@ -191,6 +254,14 @@ export default async (request: RoboRequest) => {
191254
}
192255
}
193256

257+
// 5b1. Validate message length (2000 character limit)
258+
if (body.content && body.content.length > 2000) {
259+
return new Response(JSON.stringify({ error: 'Message content exceeds 2000 characters', code: 50035 }), {
260+
status: 400,
261+
headers: { 'Content-Type': 'application/json' }
262+
})
263+
}
264+
194265
// 5c. Validate poll if present
195266
if (body.poll) {
196267
if (!body.poll.question?.text) {
@@ -261,7 +332,14 @@ export default async (request: RoboRequest) => {
261332
flags: body.flags,
262333
components: body.components,
263334
poll: body.poll,
264-
sticker_ids: body.sticker_ids
335+
sticker_ids: body.sticker_ids,
336+
message_reference: body.message_reference
337+
? {
338+
message_id: body.message_reference.message_id,
339+
channel_id: channelId,
340+
guild_id: channel.guildId
341+
}
342+
: undefined
265343
})
266344

267345
// 7. Record as 'message_sent' action
@@ -284,6 +362,15 @@ export default async (request: RoboRequest) => {
284362
}
285363
)
286364

287-
// 8. Return APIMessage response
288-
return mockMessageToAPIMessage(message, session.state.botUser)
365+
// 8. Dispatch MESSAGE_CREATE event via Gateway
366+
const author = session.state.botUser
367+
const apiMessage = mockMessageToAPIMessage(message, author)
368+
const dispatchData: Record<string, unknown> = { ...apiMessage }
369+
if (message.guildId) {
370+
dispatchData.guild_id = message.guildId
371+
}
372+
getGatewayServer().dispatchToSession(session.id, 'MESSAGE_CREATE', dispatchData, channel.guildId)
373+
374+
// 9. Return APIMessage response
375+
return apiMessage
289376
}

packages/@robojs/mock/src/api/v10/channels/[id]/messages/[messageId].ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import { enforcePermissions } from '../../../../../utils/permission-check.js'
1414
const CDN_BASE_URL = process.env.MOCK_CDN_URL || 'http://localhost:53596'
1515

1616
/**
17-
* PATCH/DELETE /api/v10/channels/:id/messages/:messageId
17+
* GET/PATCH/DELETE /api/v10/channels/:id/messages/:messageId
1818
*
19+
* GET - Fetch a single message
1920
* PATCH - Edit a message (bot can only edit its own messages)
2021
* DELETE - Delete a message
2122
*
@@ -27,12 +28,12 @@ const CDN_BASE_URL = process.env.MOCK_CDN_URL || 'http://localhost:53596'
2728
* attachments?: object[] // Attachment metadata (IDs to keep, new file metadata)
2829
* }
2930
*
30-
* Response (PATCH): APIMessage object
31+
* Response (GET/PATCH): APIMessage object
3132
* Response (DELETE): 204 No Content
3233
*/
3334
export default async (request: RoboRequest) => {
3435
// 1. Validate method
35-
if (request.method !== 'PATCH' && request.method !== 'DELETE') {
36+
if (request.method !== 'GET' && request.method !== 'PATCH' && request.method !== 'DELETE') {
3637
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
3738
status: 405,
3839
headers: { 'Content-Type': 'application/json' }
@@ -99,7 +100,11 @@ export default async (request: RoboRequest) => {
99100
if (permError) return permError
100101

101102
// 7. Handle based on method
102-
if (request.method === 'PATCH') {
103+
if (request.method === 'GET') {
104+
// GET - Return the message
105+
const author = session.state.getUser(message.authorId) || session.state.botUser
106+
return mockMessageToAPIMessage(message, author)
107+
} else if (request.method === 'PATCH') {
103108
return handlePatch(request, session, channel, message, channelId, messageId)
104109
} else {
105110
return handleDelete(session, channel, channelId, messageId)

0 commit comments

Comments
 (0)