diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2349dc7..2cae56ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **external Last.fm scrobbler**: Invalid Last.fm sessions (`error: 9`, "Invalid session key") are now auto-unlinked per user when detected during `updateNowPlaying`/`scrobble`, preventing repeated log/error spam from stale credentials. +- **Last.fm unlink resilience**: unlink operations now treat Prisma `P2025` (already absent link) as a successful cleanup path, preventing repeated error spam when invalid-session cleanup races or records are already removed. - **voice connection hardening (`/play`)**: `player.play` now receives `nodeOptions.connectionTimeout` from environment config, and watchdog recovery performs one additional rejoin wait cycle before failing. ### Changed diff --git a/packages/shared/src/services/LastFmLinkService/index.spec.ts b/packages/shared/src/services/LastFmLinkService/index.spec.ts new file mode 100644 index 000000000..ce82f9d22 --- /dev/null +++ b/packages/shared/src/services/LastFmLinkService/index.spec.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { LastFmLinkService } from './index' + +const deleteMock = + jest.fn<(args: { where: { discordId: string } }) => Promise>() +const debugLogMock = jest.fn<(payload: unknown) => void>() +const errorLogMock = jest.fn<(payload: unknown) => void>() + +jest.mock('../../utils/database/prismaClient', () => ({ + getPrismaClient: () => ({ + lastFmLink: { + delete: (args: { where: { discordId: string } }) => + deleteMock(args), + }, + }), +})) + +jest.mock('../../utils/general/log', () => ({ + debugLog: (payload: unknown) => debugLogMock(payload), + errorLog: (payload: unknown) => errorLogMock(payload), +})) + +describe('LastFmLinkService.unlink', () => { + const service = new LastFmLinkService() + + beforeEach(() => { + deleteMock.mockReset() + debugLogMock.mockReset() + errorLogMock.mockReset() + }) + + it('returns true when delete succeeds', async () => { + deleteMock.mockResolvedValue({}) + + const result = await service.unlink('123') + + expect(result).toBe(true) + expect(deleteMock).toHaveBeenCalledWith({ where: { discordId: '123' } }) + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Last.fm link removed' }), + ) + expect(errorLogMock).not.toHaveBeenCalled() + }) + + it('returns true when record is already absent (P2025)', async () => { + deleteMock.mockRejectedValue({ code: 'P2025' }) + + const result = await service.unlink('456') + + expect(result).toBe(true) + expect(debugLogMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Last.fm link already absent' }), + ) + expect(errorLogMock).not.toHaveBeenCalled() + }) + + it('returns false and logs error on unexpected failures', async () => { + const dbError = new Error('db unavailable') + deleteMock.mockRejectedValue(dbError) + + const result = await service.unlink('789') + + expect(result).toBe(false) + expect(errorLogMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Failed to unlink Last.fm' }), + ) + }) +}) diff --git a/packages/shared/src/services/LastFmLinkService/index.ts b/packages/shared/src/services/LastFmLinkService/index.ts index 5f32d19f6..465d08957 100644 --- a/packages/shared/src/services/LastFmLinkService/index.ts +++ b/packages/shared/src/services/LastFmLinkService/index.ts @@ -82,6 +82,25 @@ export class LastFmLinkService { }) return true } catch (error) { + const code = + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as { code?: unknown }).code === 'string' + ? (error as { code: string }).code + : null + + if (code === 'P2025') { + // Prisma P2025 = record not found: + // https://www.prisma.io/docs/orm/reference/error-reference + // Treat delete of a missing link as idempotent success. + debugLog({ + message: 'Last.fm link already absent', + data: { discordId }, + }) + return true + } + errorLog({ message: 'Failed to unlink Last.fm', error,