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
50 changes: 50 additions & 0 deletions fishing/apps/api/src/tournaments/leaderboard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ describe('TournamentsService 실시간 순위', () => {
user: { findUnique: jest.fn() },
tournament: { findUnique: jest.fn() },
tournamentEntry: { findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
tournamentDayCode: { count: jest.fn() },
catch: { findMany: jest.fn() },
};
const service = new TournamentsService(prisma as never);

beforeEach(() => {
jest.clearAllMocks();
prisma.tournamentDayCode.count.mockResolvedValue(0);
prisma.tournament.findUnique.mockResolvedValue({
id: 'tour-1',
organizerId: 'org-1',
Expand Down Expand Up @@ -47,6 +49,54 @@ describe('TournamentsService 실시간 순위', () => {
expect(rows).toEqual([
expect.objectContaining({ userId: 'user-1', bestLengthCm: 32.5, bestCatchId: 'catch-1', rank: 1 }),
]);
expect(prisma.catch.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.not.objectContaining({ dayCodeStatus: 'accepted' }),
}),
);
});

it('코드를 한 번이라도 등록한 대회는 수락된 제출만 집계한다', async () => {
prisma.tournamentDayCode.count.mockResolvedValue(2);
prisma.tournamentEntry.findMany.mockResolvedValue([
{
id: 'entry-1',
userId: 'user-1',
disqualified: false,
paymentStatus: 'free',
user: { id: 'user-1', nickname: '하나', profileImage: null },
},
{
id: 'entry-2',
userId: 'user-2',
disqualified: false,
paymentStatus: 'free',
user: { id: 'user-2', nickname: '둘', profileImage: null },
},
]);
prisma.catch.findMany.mockResolvedValue([
{
id: 'accepted',
userId: 'user-2',
lengthCm: 30,
createdAt: new Date('2026-11-01T04:00:00.000Z'),
fishSpeciesId: 1,
claimedTournamentId: 'tour-1',
dayCodeStatus: 'accepted',
},
]);

const rows = await service.getRanking('tour-1');

expect(rows.map((row) => row.bestCatchId)).toEqual(['accepted']);
expect(prisma.catch.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
claimedTournamentId: 'tour-1',
dayCodeStatus: 'accepted',
}),
}),
);
});

it('실격은 참가 행을 지우지 않고 표시만 바꾼다', async () => {
Expand Down
14 changes: 14 additions & 0 deletions fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ describe('실시간 대회 순위', () => {
]);
});

it('코드를 쓰는 대회는 그 대회에서 수락된 기록만 남긴다', () => {
const rows = buildLiveLeaderboard(
{ ...tournament, id: 'tour-1', requireAcceptedDayCode: true },
[entry('a'), entry('b')],
[
{ ...fish('a', 40, '2026-11-01T01:00:00.000Z'), claimedTournamentId: 'tour-1', dayCodeStatus: 'pending' },
{ ...fish('b', 30, '2026-11-01T01:00:00.000Z'), claimedTournamentId: 'tour-1', dayCodeStatus: 'accepted' },
{ ...fish('a', 55, '2026-11-01T02:00:00.000Z'), claimedTournamentId: 'other', dayCodeStatus: 'accepted' },
],
);

expect(rows.map((row) => [row.userId, row.bestLengthCm])).toEqual([['b', 30]]);
});

it('결과 CSV는 순위와 닉네임을 담는다', () => {
const csv = leaderboardCsv([
{
Expand Down
8 changes: 8 additions & 0 deletions fishing/apps/api/src/tournaments/live-leaderboard.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type LeaderboardCatch = {
status?: string;
recordType?: string;
deletedAt?: Date | null;
claimedTournamentId?: string | null;
dayCodeStatus?: string | null;
};

export type LeaderboardEntry = {
Expand All @@ -18,11 +20,13 @@ export type LeaderboardEntry = {
};

export type LeaderboardTournament = {
id?: string;
fishSpeciesId?: number | null;
minLengthCm?: number | null;
maxLengthCm?: number | null;
startAt: Date;
endAt: Date;
requireAcceptedDayCode?: boolean;
};

export type LeaderboardRow = {
Expand Down Expand Up @@ -51,6 +55,10 @@ export function buildLiveLeaderboard(
if (catch_.deletedAt) continue;
if (catch_.status && catch_.status !== 'approved') continue;
if (catch_.recordType && catch_.recordType !== 'certified') continue;
if (tournament.requireAcceptedDayCode) {
if (!tournament.id || catch_.claimedTournamentId !== tournament.id) continue;
if (catch_.dayCodeStatus !== 'accepted') continue;
}
if (catch_.createdAt < tournament.startAt || catch_.createdAt > tournament.endAt) continue;
if (tournament.fishSpeciesId && catch_.fishSpeciesId !== tournament.fishSpeciesId) continue;
if (tournament.minLengthCm != null && catch_.lengthCm < Number(tournament.minLengthCm)) continue;
Expand Down
19 changes: 17 additions & 2 deletions fishing/apps/api/src/tournaments/tournaments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class TournamentsService {
const t = await this.prisma.tournament.findUnique({
where: { id },
include: {
_count: { select: { entries: true } },
_count: { select: { entries: true, dayCodes: true } },
entries: {
where: { rank: { not: null } },
include: { user: { select: { id: true, nickname: true, profileImage: true } } },
Expand Down Expand Up @@ -91,24 +91,39 @@ export class TournamentsService {
});
if (!entries.length) return [];

const dayCodeCount = await this.prisma.tournamentDayCode.count({ where: { tournamentId } });
const requireAcceptedDayCode = dayCodeCount > 0;
const catches = await this.prisma.catch.findMany({
where: {
deletedAt: null,
status: 'approved',
recordType: 'certified',
userId: { in: entries.map((entry) => entry.userId) },
createdAt: { gte: tournament.startAt, lte: tournament.endAt },
...(requireAcceptedDayCode
? { claimedTournamentId: tournamentId, dayCodeStatus: 'accepted' }
: {}),
},
select: {
id: true,
userId: true,
lengthCm: true,
createdAt: true,
fishSpeciesId: true,
claimedTournamentId: true,
dayCodeStatus: true,
},
select: { id: true, userId: true, lengthCm: true, createdAt: true, fishSpeciesId: true },
});

return buildLiveLeaderboard(
{
id: tournament.id,
fishSpeciesId: tournament.fishSpeciesId,
minLengthCm: tournament.minLengthCm == null ? null : Number(tournament.minLengthCm),
maxLengthCm: tournament.maxLengthCm == null ? null : Number(tournament.maxLengthCm),
startAt: tournament.startAt,
endAt: tournament.endAt,
requireAcceptedDayCode,
},
entries,
catches.map((catch_) => ({
Expand Down
6 changes: 5 additions & 1 deletion fishing/apps/web/src/app/tournament/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:

<div className="detail-card">
<h3 className="detail-card-title">현재 순위</h3>
<p className="admin-muted">대회 기간의 승인된 공식 인증만 반영합니다.</p>
<p className="admin-muted">
{(data._count?.dayCodes ?? 0) > 0
? '오늘의 코드를 운영자가 수락한 공식 인증만 순위에 들어갑니다.'
: '대회 기간의 승인된 공식 인증만 반영합니다.'}
</p>
{rankingData && rankingData.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rankingData.slice(0, 10).map((entry: {
Expand Down
Loading