Skip to content
104 changes: 101 additions & 3 deletions packages/bot/src/functions/general/commands/birthday.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { describe, test, expect, jest, beforeEach } from '@jest/globals'
jest.mock('@lucky/shared/utils', () => {
const upsert = jest.fn()
const deleteMany = jest.fn()
const findMany = jest.fn()
return {
infoLog: jest.fn(),
debugLog: jest.fn(),
errorLog: jest.fn(),
getPrismaClient: () => ({ memberBirthday: { upsert, deleteMany } }),
__mocks: { upsert, deleteMany },
getPrismaClient: () => ({
memberBirthday: { upsert, deleteMany, findMany },
}),
__mocks: { upsert, deleteMany, findMany },
}
})

Expand All @@ -23,6 +26,7 @@ const { __mocks: utilsMocks } = jest.requireMock('@lucky/shared/utils') as {
deleteMany: jest.MockedFunction<
(args: unknown) => Promise<{ count: number }>
>
findMany: jest.MockedFunction<(args: unknown) => Promise<unknown>>
}
}
const { __mocks: replyMocks } = jest.requireMock(
Expand All @@ -36,9 +40,10 @@ const { __mocks: replyMocks } = jest.requireMock(
}
const memberBirthdayUpsert = utilsMocks.upsert
const memberBirthdayDeleteMany = utilsMocks.deleteMany
const memberBirthdayFindMany = utilsMocks.findMany
const interactionReply = replyMocks.interactionReply

import birthdayCommand from './birthday.js'
import birthdayCommand, { daysUntilBirthday } from './birthday.js'

function makeInteraction(
subcommand: string,
Expand All @@ -62,9 +67,28 @@ function makeInteraction(
beforeEach(() => {
memberBirthdayUpsert.mockReset().mockResolvedValue({})
memberBirthdayDeleteMany.mockReset().mockResolvedValue({ count: 1 })
memberBirthdayFindMany.mockReset().mockResolvedValue([])
interactionReply.mockClear().mockResolvedValue(undefined)
})

describe('daysUntilBirthday', () => {
test('today returns 0', () => {
const d = new Date(Date.UTC(2026, 3, 20))
expect(daysUntilBirthday(d, 4, 20)).toBe(0)
})
test('tomorrow returns 1', () => {
const d = new Date(Date.UTC(2026, 3, 20))
expect(daysUntilBirthday(d, 4, 21)).toBe(1)
})
test('past date rolls to next year', () => {
const d = new Date(Date.UTC(2026, 3, 20))
const days = daysUntilBirthday(d, 4, 19)
// April has 30 days, so 20 → next April 19 is 364 days
expect(days).toBeGreaterThan(300)
expect(days).toBeLessThan(366)
})
})

describe('/birthday', () => {
test('rejects when used in DMs', async () => {
await birthdayCommand.execute({
Expand Down Expand Up @@ -161,6 +185,80 @@ describe('/birthday', () => {
expect(args.content.content).toContain('No birthday')
})

test('list shows empty state when no birthdays set', async () => {
memberBirthdayFindMany.mockResolvedValueOnce([])
await birthdayCommand.execute({
interaction: {
...makeInteraction('list'),
guild: { id: 'guild-1', name: 'TestGuild' },
} as never,
})
const args = interactionReply.mock.calls[0][0] as {
content: { content: string }
}
expect(args.content.content).toContain('No birthdays')
})

test('list sorts by upcoming and caps at 5', async () => {
memberBirthdayFindMany.mockResolvedValueOnce([
{ userId: 'a', month: 12, day: 25 },
{ userId: 'b', month: 1, day: 1 },
{ userId: 'c', month: 7, day: 4 },
{ userId: 'd', month: 3, day: 15 },
{ userId: 'e', month: 6, day: 10 },
{ userId: 'f', month: 11, day: 11 },
{ userId: 'g', month: 2, day: 2 },
])
await birthdayCommand.execute({
interaction: {
...makeInteraction('list'),
guild: { id: 'guild-1', name: 'TestGuild' },
} as never,
})
const args = interactionReply.mock.calls[0][0] as {
content: { embeds: Array<{ description: string; footer: { text: string } }> }
}
const description = args.content.embeds[0].description
// Should show exactly 5 bullet lines
expect(description.split('\n').length).toBe(5)
expect(args.content.embeds[0].footer.text).toContain('5 of 7')
})

test('list labels same-day match as "today"', async () => {
const now = new Date()
const todayMonth = now.getUTCMonth() + 1
const todayDay = now.getUTCDate()
memberBirthdayFindMany.mockResolvedValueOnce([
{ userId: 'birthday-user', month: todayMonth, day: todayDay },
])
await birthdayCommand.execute({
interaction: {
...makeInteraction('list'),
guild: { id: 'guild-1', name: 'TestGuild' },
} as never,
})
const args = interactionReply.mock.calls[0][0] as {
content: { embeds: Array<{ description: string }> }
}
expect(args.content.embeds[0].description).toContain('today')
})

test('list does not mention users in output (parse: [])', async () => {
memberBirthdayFindMany.mockResolvedValueOnce([
{ userId: 'u1', month: 3, day: 15 },
])
await birthdayCommand.execute({
interaction: {
...makeInteraction('list'),
guild: { id: 'guild-1', name: 'TestGuild' },
} as never,
})
const args = interactionReply.mock.calls[0][0] as {
content: { allowedMentions: { parse: unknown[] } }
}
expect(args.content.allowedMentions).toEqual({ parse: [] })
})

test('handles upsert throwing gracefully', async () => {
memberBirthdayUpsert.mockRejectedValueOnce(new Error('db down'))
await birthdayCommand.execute({
Expand Down
82 changes: 82 additions & 0 deletions packages/bot/src/functions/general/commands/birthday.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@
return `${MONTHS[month - 1]} ${day}`
}

// Days from `from` to the next occurrence of (month, day). 0 = today, 1 =
// tomorrow, ..., 365/366 on the same date next year.
export function daysUntilBirthday(
from: Date,
month: number,
day: number,
): number {
const year = from.getUTCFullYear()
const todayUtc = Date.UTC(
year,
from.getUTCMonth(),
from.getUTCDate(),
)
let target = Date.UTC(year, month - 1, day)
if (target < todayUtc) {
target = Date.UTC(year + 1, month - 1, day)
}
const ms = target - todayUtc
return Math.round(ms / (24 * 60 * 60 * 1000))
}

function validateDate(
month: number,
day: number,
Expand Down Expand Up @@ -74,6 +95,13 @@
sub
.setName('clear')
.setDescription('Remove your birthday from this server.'),
)
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription(
'Show the next upcoming birthdays in this server.',
),
),
category: 'general',
execute: async ({ interaction }) => {
Expand Down Expand Up @@ -142,6 +170,60 @@
return
}

if (subcommand === 'list') {
const rows = (await prisma.memberBirthday.findMany({
where: { guildId: guild.id },
select: { userId: true, month: true, day: true },
})) as Array<{ userId: string; month: number; day: number }>

if (rows.length === 0) {
await interactionReply({
interaction,
content: {
content:
'No birthdays set yet. Use `/birthday set` to add yours.',
},
})
return
}

const now = new Date()
const annotated = rows
.map((r) => ({
...r,
daysUntil: daysUntilBirthday(now, r.month, r.day),
}))
.sort((a, b) => a.daysUntil - b.daysUntil)
.slice(0, 5)

const lines = annotated.map((r) => {
const label =
r.daysUntil === 0
? '**today**'
: r.daysUntil === 1
? 'tomorrow'
: `in ${r.daysUntil} days`

Check warning on line 205 in packages/bot/src/functions/general/commands/birthday.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2o9oUMvn5gzXa5Q0M-&open=AZ2o9oUMvn5gzXa5Q0M-&pullRequest=742
return `• <@${r.userId}> — ${formatBirthday(r.month, r.day)} (${label})`
})

const embed = new EmbedBuilder()
.setTitle('🎂 Upcoming Birthdays')
.setDescription(lines.join('\n'))
.setColor(COLOR.LUCKY_PURPLE)
.setFooter({
text: `Showing ${annotated.length} of ${rows.length} · ${guild.name}`,
})

await interactionReply({
interaction,
content: {
embeds: [embed.toJSON()],
allowedMentions: { parse: [] },
},
})
return
}

if (subcommand === 'clear') {
const deleted = await prisma.memberBirthday.deleteMany({
where: {
Expand Down
Loading