diff --git a/packages/shared/src/services/ReactionRolesService/index.spec.ts b/packages/shared/src/services/ReactionRolesService/index.spec.ts index 5edd7a4ee..092d1cfbc 100644 --- a/packages/shared/src/services/ReactionRolesService/index.spec.ts +++ b/packages/shared/src/services/ReactionRolesService/index.spec.ts @@ -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(() => { @@ -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', @@ -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) @@ -1128,7 +1140,13 @@ describe('ReactionRolesService', () => { title: 'Test', description: 'Test', botToken: 'token', - roles: [{ roleId: '11111111111111111', label: 'L1', emoji: '' }], + roles: [ + { + roleId: '11111111111111111', + label: 'L1', + emoji: '', + }, + ], } await service.createReactionRoleMessageFromDashboard(options) @@ -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) @@ -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) @@ -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', @@ -2285,12 +2378,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( @@ -2298,9 +2391,7 @@ describe('ReactionRolesService', () => { ) expect(result.messageId).toBe('msg-123') - expect( - mockPrisma.reactionRoleMessage.create, - ).toHaveBeenCalled() + expect(mockPrisma.reactionRoleMessage.create).toHaveBeenCalled() }) it('handles null emoji in buildButtonRows', async () => { @@ -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( @@ -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, }) diff --git a/packages/shared/src/services/ReactionRolesService/index.ts b/packages/shared/src/services/ReactionRolesService/index.ts index e703302d7..db6f89d93 100644 --- a/packages/shared/src/services/ReactionRolesService/index.ts +++ b/packages/shared/src/services/ReactionRolesService/index.ts @@ -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 }, }), prisma.reactionRoleMessage.update({ where: { messageId }, @@ -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 },