Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 129 additions & 35 deletions packages/shared/src/services/ReactionRolesService/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,20 @@ describe('ReactionRolesService', () => {
}
mockGetPrismaClient.mockReturnValue(mockPrisma)
mockIsEnabled.mockResolvedValue(true)
;(mockPrisma.$transaction as any).mockImplementation(async (fnOrArray: any) => {
if (typeof fnOrArray === 'function') {
return await fnOrArray(mockPrisma)
}
// Handle array pattern: execute each operation
const results = []
for (const op of fnOrArray) {
// Each op is a Prisma operation (like deleteMany result)
results.push(op)
}
return results
})
;(mockPrisma.$transaction as any).mockImplementation(
async (fnOrArray: any) => {
if (typeof fnOrArray === 'function') {
return await fnOrArray(mockPrisma)
}
// Handle array pattern: execute each operation
const results = []
for (const op of fnOrArray) {
// Each op is a Prisma operation (like deleteMany result)
results.push(op)
}
return results
},
)
})

afterEach(() => {
Expand Down Expand Up @@ -500,7 +502,11 @@ describe('ReactionRolesService', () => {
const options = {
...baseOptions,
roles: [
{ roleId: '11111111111111111', label: 'Role One', emoji: '1️⃣' },
{
roleId: '11111111111111111',
label: 'Role One',
emoji: '1️⃣',
},
{
roleId: '22222222222222222',
label: 'Role Two',
Expand Down Expand Up @@ -686,7 +692,13 @@ describe('ReactionRolesService', () => {

const options = {
...baseOptions,
roles: [{ roleId: '11111111111111111', label: 'Heart', emoji: '❤️' }],
roles: [
{
roleId: '11111111111111111',
label: 'Heart',
emoji: '❤️',
},
],
}
await service.createReactionRoleMessageFromDashboard(options)

Expand Down Expand Up @@ -1128,7 +1140,13 @@ describe('ReactionRolesService', () => {
title: 'Test',
description: 'Test',
botToken: 'token',
roles: [{ roleId: '11111111111111111', label: 'L1', emoji: '<a:fire:12345>' }],
roles: [
{
roleId: '11111111111111111',
label: 'L1',
emoji: '<a:fire:12345>',
},
],
}
await service.createReactionRoleMessageFromDashboard(options)

Expand Down Expand Up @@ -1156,7 +1174,13 @@ describe('ReactionRolesService', () => {
title: 'Test',
description: 'Test',
botToken: 'token',
roles: [{ roleId: '11111111111111111', label: 'L1', emoji: '<:star:98765>' }],
roles: [
{
roleId: '11111111111111111',
label: 'L1',
emoji: '<:star:98765>',
},
],
}
await service.createReactionRoleMessageFromDashboard(options)

Expand Down Expand Up @@ -1184,7 +1208,9 @@ describe('ReactionRolesService', () => {
title: 'Test',
description: 'Test',
botToken: 'token',
roles: [{ roleId: '11111111111111111', label: 'L1', emoji: '✨' }],
roles: [
{ roleId: '11111111111111111', label: 'L1', emoji: '✨' },
],
}
await service.createReactionRoleMessageFromDashboard(options)

Expand Down Expand Up @@ -1360,6 +1386,73 @@ describe('ReactionRolesService', () => {
)
})

it('deletes old mappings by message cuid, not Discord snowflake (#1675)', async () => {
// ReactionRoleMapping.messageId is a FK to ReactionRoleMessage.id
// (a cuid). Using the Discord snowflake matched zero rows, so the
// nested create collided on @@unique([messageId, roleId]) -> 400.
mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({
id: 'cuid-internal-1',
messageId: '98765432109876543',
guildId: '12345678901234567',
channelId: '11111111111111111',
mappings: [],
})
const mockResponse: any = {
ok: true,
json: (jest.fn() as any).mockResolvedValue({
id: '98765432109876543',
}),
}
;(global as any).fetch.mockResolvedValueOnce(mockResponse)
mockPrisma.$transaction.mockResolvedValueOnce({})

await service.updateReactionRoleMessage(baseOptions)

expect(
mockPrisma.reactionRoleMapping.deleteMany,
).toHaveBeenCalledWith({
where: { messageId: 'cuid-internal-1' },
})
})

it('rollback also deletes by cuid when the Discord update fails (#1675)', async () => {
mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({
id: 'cuid-internal-2',
messageId: '98765432109876543',
guildId: '12345678901234567',
channelId: '11111111111111111',
title: 'old',
description: 'old',
imageUrl: null,
mappings: [],
})
// First $transaction (the update) succeeds; the Discord PATCH then
// fails, triggering the rollback $transaction.
mockPrisma.$transaction.mockResolvedValueOnce({})
mockPrisma.$transaction.mockResolvedValueOnce({})
;(global as any).fetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: (jest.fn() as any).mockResolvedValue('err'),
})

await expect(
service.updateReactionRoleMessage(baseOptions),
).rejects.toThrow('Discord API error 500')

// Every deleteMany (update + rollback) must target the cuid, never
// the Discord snowflake.
for (const call of mockPrisma.reactionRoleMapping.deleteMany.mock
.calls) {
expect(call[0]).toEqual({
where: { messageId: 'cuid-internal-2' },
})
}
expect(
mockPrisma.reactionRoleMapping.deleteMany,
).toHaveBeenCalledTimes(2)
})

it('includes imageUrl in PATCH body embed when provided', async () => {
mockPrisma.reactionRoleMessage.findUnique.mockResolvedValueOnce({
messageId: '98765432109876543',
Expand Down Expand Up @@ -2285,22 +2378,20 @@ describe('ReactionRolesService', () => {
id: 'msg-id-1',
messageId: 'msg-123',
})
;(global as any).fetch = (jest
.fn() as any)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'msg-123' }),
})
;(global as any).fetch = (
jest.fn() as any
).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'msg-123' }),
})

const result =
await service.createReactionRoleMessageFromDashboard(
validOptions,
)

expect(result.messageId).toBe('msg-123')
expect(
mockPrisma.reactionRoleMessage.create,
).toHaveBeenCalled()
expect(mockPrisma.reactionRoleMessage.create).toHaveBeenCalled()
})

it('handles null emoji in buildButtonRows', async () => {
Expand All @@ -2325,12 +2416,12 @@ describe('ReactionRolesService', () => {
id: 'msg-id-1',
messageId: 'msg-123',
})
;(global as any).fetch = (jest
.fn() as any)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'msg-123' }),
})
;(global as any).fetch = (
jest.fn() as any
).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'msg-123' }),
})

const result =
await service.createReactionRoleMessageFromDashboard(
Expand Down Expand Up @@ -2592,14 +2683,17 @@ describe('ReactionRolesService', () => {
const mockTx = {
reactionRoleMapping: {
count: (jest.fn() as any).mockResolvedValue(1),
findFirst: (jest.fn() as any).mockResolvedValue(null),
create: (jest.fn() as any).mockResolvedValue(createdMapping),
findFirst: (jest.fn() as any).mockResolvedValue(
null,
),
create: (jest.fn() as any).mockResolvedValue(
createdMapping,
),
},
}
return callback(mockTx)
},
)

;(global as any).fetch = (jest.fn() as any).mockResolvedValueOnce({
ok: true,
})
Expand Down
7 changes: 5 additions & 2 deletions packages/shared/src/services/ReactionRolesService/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,9 @@ export class ReactionRolesService {
// Update the database first with new mappings (DB-first approach)
await prisma.$transaction([
prisma.reactionRoleMapping.deleteMany({
where: { messageId },
// FK ReactionRoleMapping.messageId references ReactionRoleMessage.id
// (a cuid), NOT the Discord snowflake — see #1675.
where: { messageId: message.id },
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}),
prisma.reactionRoleMessage.update({
where: { messageId },
Expand Down Expand Up @@ -572,7 +574,8 @@ export class ReactionRolesService {
try {
await prisma.$transaction([
prisma.reactionRoleMapping.deleteMany({
where: { messageId },
// cuid FK, not the Discord snowflake — see #1675.
where: { messageId: message.id },
}),
prisma.reactionRoleMessage.update({
where: { messageId },
Expand Down
Loading