diff --git a/src/app/api/leagues/[leagueId]/calendar/route.test.ts b/src/app/api/leagues/[leagueId]/calendar/route.test.ts new file mode 100644 index 0000000..04f27e1 --- /dev/null +++ b/src/app/api/leagues/[leagueId]/calendar/route.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + prisma: { + league: { + findUnique: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + leagueMembership: { + findUnique: vi.fn(), + }, + member: { + findUnique: vi.fn(), + }, + series: { + findMany: vi.fn(), + }, + }, + getIracingCustIdFromJwt: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mocks.prisma, +})); + +vi.mock("@/lib/auth/iracing", () => ({ + getIracingCustIdFromJwt: mocks.getIracingCustIdFromJwt, +})); + +import { GET } from "./route"; + +function buildRequest(accessToken?: string): NextRequest { + return { + cookies: { + get: vi.fn((name: string) => + name === "irh_access_token" && accessToken + ? { value: accessToken } + : undefined, + ), + }, + } as unknown as NextRequest; +} + +function mockLeague() { + mocks.prisma.league.findUnique.mockResolvedValue({ + id: "league-1", + iracingLeagueId: 101, + }); +} + +const FUTURE_DATE = new Date("2099-06-01T18:00:00.000Z"); + +function makeSchedule(overrides = {}) { + return { + id: "schedule-1", + eventDate: FUTURE_DATE, + raceName: "Round 1", + isOffWeek: false, + pointsCount: true, + canDrop: false, + registrationEnabled: true, + trackName: null, + trackId: null, + raceLength: null, + raceOrder: 1, + iracingSessionId: null, + importedSession: null, + registrations: [], + ...overrides, + }; +} + +function mockSeriesWithSchedule(overrides = {}) { + mocks.prisma.series.findMany.mockResolvedValue([ + { + id: "series-1", + name: "Pro Series", + description: null, + isActive: true, + seasons: [ + { + id: "season-1", + seasonName: "Season 1", + description: null, + isActive: true, + numDrops: 0, + iracingSeasonId: null, + schedules: [makeSchedule(overrides)], + }, + ], + }, + ]); +} + +describe("GET /api/leagues/[leagueId]/calendar", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 404 when league is not found", async () => { + mocks.prisma.league.findUnique.mockResolvedValue(null); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "unknown" }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: "league_not_found", + }); + }); + + it("returns 200 with isAdmin=false and isRegisteredByMe=false for unauthenticated request", async () => { + mockLeague(); + mockSeriesWithSchedule(); + + const response = await GET(buildRequest(/* no token */), { + params: Promise.resolve({ leagueId: "101" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + isAdmin: boolean; + series: Array<{ + seasons: Array<{ + schedules: Array<{ + isRegisteredByMe: boolean; + registeredMembers: unknown[]; + }>; + }>; + }>; + }; + + expect(payload.isAdmin).toBe(false); + const schedule = payload.series[0].seasons[0].schedules[0]; + expect(schedule.isRegisteredByMe).toBe(false); + expect(schedule.registeredMembers).toEqual([]); + }); + + it("returns isAdmin=false and isRegisteredByMe=false when auth token is invalid", async () => { + mockLeague(); + mockSeriesWithSchedule(); + mocks.getIracingCustIdFromJwt.mockImplementation(() => { + throw new Error("invalid token"); + }); + + const response = await GET(buildRequest("bad-token"), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + isAdmin: boolean; + }; + + expect(payload.isAdmin).toBe(false); + }); + + it("returns isAdmin=true for league admin and shows registered members", async () => { + mockLeague(); + + const member = { + id: "member-1", + custId: 9001, + displayName: "Driver One", + carNumber: "42", + nickName: null, + }; + + mocks.prisma.series.findMany.mockResolvedValue([ + { + id: "series-1", + name: "Pro Series", + description: null, + isActive: true, + seasons: [ + { + id: "season-1", + seasonName: "Season 1", + description: null, + isActive: true, + numDrops: 0, + iracingSeasonId: null, + schedules: [ + makeSchedule({ + registrations: [ + { + id: "reg-1", + createdAt: FUTURE_DATE, + memberId: "member-1", + member, + }, + ], + }), + ], + }, + ], + }, + ]); + + mocks.getIracingCustIdFromJwt.mockReturnValue(9001); + mocks.prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + iracingCustId: 9001, + }); + mocks.prisma.leagueMembership.findUnique.mockResolvedValue({ + owner: false, + admin: true, + }); + mocks.prisma.member.findUnique.mockResolvedValue({ + id: "member-1", + }); + + const response = await GET(buildRequest("valid-token"), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + isAdmin: boolean; + series: Array<{ + seasons: Array<{ + schedules: Array<{ + isRegisteredByMe: boolean; + registeredMembers: Array<{ id: string }>; + }>; + }>; + }>; + }; + + expect(payload.isAdmin).toBe(true); + const schedule = payload.series[0].seasons[0].schedules[0]; + expect(schedule.isRegisteredByMe).toBe(true); + expect(schedule.registeredMembers).toHaveLength(1); + expect(schedule.registeredMembers[0].id).toBe("reg-1"); + }); + + it("hides registeredMembers for non-admin authenticated member", async () => { + mockLeague(); + + mocks.prisma.series.findMany.mockResolvedValue([ + { + id: "series-1", + name: "Pro Series", + description: null, + isActive: true, + seasons: [ + { + id: "season-1", + seasonName: "Season 1", + description: null, + isActive: true, + numDrops: 0, + iracingSeasonId: null, + schedules: [ + makeSchedule({ + registrations: [ + { + id: "reg-1", + createdAt: FUTURE_DATE, + memberId: "member-1", + member: { + id: "member-1", + custId: 9001, + displayName: "Driver One", + carNumber: null, + nickName: null, + }, + }, + ], + }), + ], + }, + ], + }, + ]); + + mocks.getIracingCustIdFromJwt.mockReturnValue(9001); + mocks.prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + iracingCustId: 9001, + }); + mocks.prisma.leagueMembership.findUnique.mockResolvedValue({ + owner: false, + admin: false, + }); + mocks.prisma.member.findUnique.mockResolvedValue({ id: "member-1" }); + + const response = await GET(buildRequest("valid-token"), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + isAdmin: boolean; + series: Array<{ + seasons: Array<{ + schedules: Array<{ registeredMembers: unknown[] }>; + }>; + }>; + }; + + expect(payload.isAdmin).toBe(false); + expect(payload.series[0].seasons[0].schedules[0].registeredMembers).toEqual( + [], + ); + }); +}); diff --git a/src/app/api/leagues/[leagueId]/calendar/route.ts b/src/app/api/leagues/[leagueId]/calendar/route.ts index 98143ff..0e68893 100644 --- a/src/app/api/leagues/[leagueId]/calendar/route.ts +++ b/src/app/api/leagues/[leagueId]/calendar/route.ts @@ -10,9 +10,6 @@ export async function GET( const { leagueId: rawLeagueId } = await params; const accessToken = request.cookies.get("irh_access_token")?.value; - if (!accessToken) { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); - } // leagueId may be either the DB id or the iRacing numeric league ID const iracingLeagueIdNum = parseInt(rawLeagueId, 10); @@ -32,34 +29,42 @@ export async function GET( const leagueDbId = league.id; - const iracingCustId = getIracingCustIdFromJwt(accessToken); - const user = await prisma.user.findUnique({ - where: { iracingCustId }, - select: { id: true, iracingCustId: true }, - }); - if (!user) { - return NextResponse.json({ error: "user_not_found" }, { status: 404 }); - } + let isAdmin = false; + let member: { id: string } | null = null; - const membership = await prisma.leagueMembership.findUnique({ - where: { userId_leagueId: { userId: user.id, leagueId: leagueDbId } }, - select: { owner: true, admin: true }, - }); - if (!membership) { - return NextResponse.json({ error: "not_a_member" }, { status: 403 }); - } + if (accessToken) { + try { + const iracingCustId = getIracingCustIdFromJwt(accessToken); + const user = await prisma.user.findUnique({ + where: { iracingCustId }, + select: { id: true, iracingCustId: true }, + }); - const isAdmin = membership.owner || membership.admin; + if (user) { + const membership = await prisma.leagueMembership.findUnique({ + where: { + userId_leagueId: { userId: user.id, leagueId: leagueDbId }, + }, + select: { owner: true, admin: true }, + }); - const member = await prisma.member.findUnique({ - where: { - leagueId_custId: { - leagueId: leagueDbId, - custId: user.iracingCustId, - }, - }, - select: { id: true }, - }); + if (membership) { + isAdmin = membership.owner || membership.admin; + member = await prisma.member.findUnique({ + where: { + leagueId_custId: { + leagueId: leagueDbId, + custId: user.iracingCustId, + }, + }, + select: { id: true }, + }); + } + } + } catch { + // ignore auth errors — serve public data + } + } const series = await prisma.series.findMany({ where: { leagueId: leagueDbId }, diff --git a/src/app/api/leagues/[leagueId]/landing/route.test.ts b/src/app/api/leagues/[leagueId]/landing/route.test.ts new file mode 100644 index 0000000..e271596 --- /dev/null +++ b/src/app/api/leagues/[leagueId]/landing/route.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + prisma: { + league: { + findUnique: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + leagueMembership: { + findUnique: vi.fn(), + }, + member: { + findUnique: vi.fn(), + }, + series: { + findMany: vi.fn(), + }, + }, + getIracingCustIdFromJwt: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mocks.prisma, +})); + +vi.mock("@/lib/auth/iracing", () => ({ + getIracingCustIdFromJwt: mocks.getIracingCustIdFromJwt, +})); + +import { GET } from "./route"; + +function buildRequest(accessToken = "token"): NextRequest { + return { + cookies: { + get: vi.fn((name: string) => + name === "irh_access_token" && accessToken + ? { value: accessToken } + : undefined, + ), + }, + } as unknown as NextRequest; +} + +function mockBaseLeagueAndUser() { + mocks.getIracingCustIdFromJwt.mockReturnValue(12345); + + mocks.prisma.league.findUnique.mockResolvedValue({ + id: "league-1", + iracingLeagueId: 101, + leagueName: "Test League", + smallLogo: null, + largeLogo: null, + rosterCount: 10, + about: null, + message: null, + virtualModeEnabled: false, + virtualEntryFee: 0, + }); + + mocks.prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + iracingCustId: 12345, + }); + + mocks.prisma.series.findMany.mockResolvedValue([]); +} + +describe("GET /api/leagues/[leagueId]/landing", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 200 for authenticated non-members and disables self-registration", async () => { + mockBaseLeagueAndUser(); + mocks.prisma.leagueMembership.findUnique.mockResolvedValue(null); + mocks.prisma.member.findUnique.mockResolvedValue(null); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + isAdmin: boolean; + canSelfRegister: boolean; + series: unknown[]; + }; + + expect(payload.isAdmin).toBe(false); + expect(payload.canSelfRegister).toBe(false); + expect(payload.series).toEqual([]); + }); + + it("keeps admin and self-registration enabled for valid members", async () => { + mockBaseLeagueAndUser(); + mocks.prisma.leagueMembership.findUnique.mockResolvedValue({ + owner: false, + admin: true, + }); + mocks.prisma.member.findUnique.mockResolvedValue({ + id: "member-1", + displayName: "Driver One", + }); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + isAdmin: boolean; + canSelfRegister: boolean; + }; + + expect(payload.isAdmin).toBe(true); + expect(payload.canSelfRegister).toBe(true); + }); + + it("returns 200 when access token is missing and keeps actions disabled", async () => { + mockBaseLeagueAndUser(); + + const response = await GET(buildRequest(""), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + isAdmin: boolean; + canSelfRegister: boolean; + }; + + expect(payload.isAdmin).toBe(false); + expect(payload.canSelfRegister).toBe(false); + }); +}); diff --git a/src/app/api/leagues/[leagueId]/landing/route.ts b/src/app/api/leagues/[leagueId]/landing/route.ts index 01be2c0..2ff1999 100644 --- a/src/app/api/leagues/[leagueId]/landing/route.ts +++ b/src/app/api/leagues/[leagueId]/landing/route.ts @@ -161,10 +161,6 @@ export async function GET( const { leagueId: rawLeagueId } = await params; const accessToken = request.cookies.get("irh_access_token")?.value; - if (!accessToken) { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); - } - const iracingLeagueIdNum = parseInt(rawLeagueId, 10); const league = Number.isNaN(iracingLeagueIdNum) ? await prisma.league.findUnique({ @@ -202,37 +198,54 @@ export async function GET( return NextResponse.json({ error: "league_not_found" }, { status: 404 }); } - const iracingCustId = getIracingCustIdFromJwt(accessToken); - const user = await prisma.user.findUnique({ - where: { iracingCustId }, - select: { id: true, iracingCustId: true }, - }); - - if (!user) { - return NextResponse.json({ error: "user_not_found" }, { status: 404 }); - } - - const membership = await prisma.leagueMembership.findUnique({ - where: { userId_leagueId: { userId: user.id, leagueId: league.id } }, - select: { owner: true, admin: true }, - }); + let authUser: + | { + id: string; + iracingCustId: number; + } + | null = null; + let membership: + | { + owner: boolean; + admin: boolean; + } + | null = null; + + if (accessToken) { + try { + const iracingCustId = getIracingCustIdFromJwt(accessToken); + authUser = await prisma.user.findUnique({ + where: { iracingCustId }, + select: { id: true, iracingCustId: true }, + }); - if (!membership) { - return NextResponse.json({ error: "not_a_member" }, { status: 403 }); + if (authUser) { + membership = await prisma.leagueMembership.findUnique({ + where: { + userId_leagueId: { userId: authUser.id, leagueId: league.id }, + }, + select: { owner: true, admin: true }, + }); + } + } catch (authError) { + console.warn("[league landing route] failed to resolve auth", authError); + } } - const isAdmin = membership.owner || membership.admin; + const isAdmin = Boolean(membership?.owner || membership?.admin); const [currentMember, series] = await Promise.all([ - prisma.member.findUnique({ - where: { - leagueId_custId: { - leagueId: league.id, - custId: user.iracingCustId, - }, - }, - select: { id: true, displayName: true }, - }), + authUser + ? prisma.member.findUnique({ + where: { + leagueId_custId: { + leagueId: league.id, + custId: authUser.iracingCustId, + }, + }, + select: { id: true, displayName: true }, + }) + : Promise.resolve(null), prisma.series.findMany({ where: { leagueId: league.id, isActive: true }, orderBy: { name: "asc" }, @@ -455,7 +468,7 @@ export async function GET( return NextResponse.json({ league, isAdmin, - canSelfRegister: Boolean(currentMember), + canSelfRegister: Boolean(membership && currentMember), series: seriesCards, }); } catch (error) { diff --git a/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/registration/route.test.ts b/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/registration/route.test.ts new file mode 100644 index 0000000..050c4c3 --- /dev/null +++ b/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/registration/route.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + prisma: { + user: { + findUnique: vi.fn(), + }, + leagueMembership: { + findUnique: vi.fn(), + }, + member: { + findUnique: vi.fn(), + }, + schedule: { + findUnique: vi.fn(), + }, + eventRegistration: { + findMany: vi.fn(), + }, + }, + getIracingCustIdFromJwt: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mocks.prisma, +})); + +vi.mock("@/lib/auth/iracing", () => ({ + getIracingCustIdFromJwt: mocks.getIracingCustIdFromJwt, +})); + +import { GET } from "./route"; + +function buildRequest(accessToken = "token"): NextRequest { + return { + cookies: { + get: vi.fn((name: string) => + name === "irh_access_token" && accessToken + ? { value: accessToken } + : undefined, + ), + }, + } as unknown as NextRequest; +} + +function mockBaseContext(args?: { admin?: boolean }) { + const admin = args?.admin ?? false; + + mocks.getIracingCustIdFromJwt.mockReturnValue(9001); + mocks.prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + iracingCustId: 9001, + }); + mocks.prisma.leagueMembership.findUnique.mockResolvedValue({ + owner: false, + admin, + }); + mocks.prisma.member.findUnique.mockResolvedValue({ + id: "member-1", + custId: 9001, + displayName: "Driver One", + earnedVirtual: 0, + }); + mocks.prisma.schedule.findUnique.mockResolvedValue({ + id: "schedule-1", + raceName: "Round 1", + eventDate: new Date("2099-01-01T00:00:00.000Z"), + registrationEnabled: true, + virtualEntryFee: 0, + importedSession: { + hasResults: false, + }, + series: { + leagueId: "league-1", + }, + }); +} + +describe("GET /api/leagues/[leagueId]/schedules/[scheduleId]/registration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when token is missing", async () => { + const response = await GET(buildRequest(""), { + params: Promise.resolve({ + leagueId: "league-1", + scheduleId: "schedule-1", + }), + }); + + if (!response) { + throw new Error("Expected a response"); + } + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }); + }); + + it("returns 403 when requester is not a league member", async () => { + mocks.getIracingCustIdFromJwt.mockReturnValue(9001); + mocks.prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + iracingCustId: 9001, + }); + mocks.prisma.leagueMembership.findUnique.mockResolvedValue(null); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ + leagueId: "league-1", + scheduleId: "schedule-1", + }), + }); + + if (!response) { + throw new Error("Expected a response"); + } + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "not_a_member" }); + }); + + it("hides member registration roster for non-admin members", async () => { + mockBaseContext({ admin: false }); + mocks.prisma.eventRegistration.findMany.mockResolvedValue([ + { + id: "reg-1", + memberId: "member-1", + createdAt: new Date("2099-01-01T00:00:00.000Z"), + member: { + id: "member-1", + custId: 9001, + displayName: "Driver One", + carNumber: null, + nickName: null, + }, + }, + ]); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ + leagueId: "league-1", + scheduleId: "schedule-1", + }), + }); + + if (!response) { + throw new Error("Expected a response"); + } + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + isRegistered: boolean; + registrationCount: number; + registrations?: unknown[]; + }; + + expect(payload.isRegistered).toBe(true); + expect(payload.registrationCount).toBe(1); + expect(payload.registrations).toBeUndefined(); + }); + + it("includes member registration roster for admins", async () => { + mockBaseContext({ admin: true }); + mocks.prisma.eventRegistration.findMany.mockResolvedValue([ + { + id: "reg-1", + memberId: "member-1", + createdAt: new Date("2099-01-01T00:00:00.000Z"), + member: { + id: "member-1", + custId: 9001, + displayName: "Driver One", + carNumber: null, + nickName: null, + }, + }, + ]); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ + leagueId: "league-1", + scheduleId: "schedule-1", + }), + }); + + if (!response) { + throw new Error("Expected a response"); + } + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + registrations?: Array<{ id: string }>; + }; + + expect(payload.registrations).toHaveLength(1); + expect(payload.registrations?.[0]?.id).toBe("reg-1"); + }); +}); diff --git a/src/app/api/leagues/[leagueId]/standings/route.test.ts b/src/app/api/leagues/[leagueId]/standings/route.test.ts new file mode 100644 index 0000000..990440c --- /dev/null +++ b/src/app/api/leagues/[leagueId]/standings/route.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + prisma: { + league: { + findUnique: vi.fn(), + }, + raceSessionResult: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mocks.prisma, +})); + +import { GET } from "./route"; + +function buildRequest(): Request { + return {} as Request; +} + +function mockLeague() { + mocks.prisma.league.findUnique.mockResolvedValue({ + id: "league-1", + iracingLeagueId: 101, + leagueName: "Test League", + }); +} + +describe("GET /api/leagues/[leagueId]/standings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 404 when league is not found", async () => { + mocks.prisma.league.findUnique.mockResolvedValue(null); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "unknown-league" }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: "league_not_found", + }); + }); + + it("returns 200 with empty standings for unauthenticated request", async () => { + mockLeague(); + mocks.prisma.raceSessionResult.findMany.mockResolvedValue([]); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + league: { id: string; leagueName: string }; + overall: unknown[]; + bySeriesSeason: unknown[]; + }; + + expect(payload.league.id).toBe("league-1"); + expect(payload.overall).toEqual([]); + expect(payload.bySeriesSeason).toEqual([]); + }); + + it("resolves league by numeric iRacing ID", async () => { + mocks.prisma.league.findUnique.mockResolvedValue({ + id: "league-1", + iracingLeagueId: 101, + leagueName: "Test League", + }); + mocks.prisma.raceSessionResult.findMany.mockResolvedValue([]); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "101" }), + }); + + expect(response.status).toBe(200); + // numeric id → findUnique called with iracingLeagueId + expect(mocks.prisma.league.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { iracingLeagueId: 101 }, + }), + ); + }); + + it("computes overall standings and by-series buckets from results", async () => { + mockLeague(); + + mocks.prisma.raceSessionResult.findMany.mockResolvedValue([ + { + custId: 1, + displayName: "Alice", + finalPoints: 50, + finishPosition: 1, + raceSession: { + series: { id: "series-1", name: "Pro Series" }, + season: { id: "season-1", seasonName: "Season 1" }, + }, + }, + { + custId: 2, + displayName: "Bob", + finalPoints: 40, + finishPosition: 2, + raceSession: { + series: { id: "series-1", name: "Pro Series" }, + season: { id: "season-1", seasonName: "Season 1" }, + }, + }, + { + custId: 1, + displayName: "Alice", + finalPoints: 45, + finishPosition: 1, + raceSession: { + series: { id: "series-1", name: "Pro Series" }, + season: { id: "season-1", seasonName: "Season 1" }, + }, + }, + ]); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + overall: Array<{ + custId: number; + points: number; + wins: number; + starts: number; + gapToLeader: number; + }>; + bySeriesSeason: Array<{ + seriesId: string; + seasonId: string; + standings: unknown[]; + }>; + }; + + // Alice leads with 95 pts (2 wins, 2 starts), Bob has 40 (1 start) + const [first, second] = payload.overall; + expect(first.custId).toBe(1); + expect(first.points).toBe(95); + expect(first.wins).toBe(2); + expect(first.starts).toBe(2); + expect(first.gapToLeader).toBe(0); + + expect(second.custId).toBe(2); + expect(second.points).toBe(40); + expect(second.gapToLeader).toBe(55); + + // single series/season bucket + expect(payload.bySeriesSeason).toHaveLength(1); + expect(payload.bySeriesSeason[0].seriesId).toBe("series-1"); + expect(payload.bySeriesSeason[0].seasonId).toBe("season-1"); + }); + + it("buckets results by series+season", async () => { + mockLeague(); + + mocks.prisma.raceSessionResult.findMany.mockResolvedValue([ + { + custId: 1, + displayName: "Alice", + finalPoints: 50, + finishPosition: 1, + raceSession: { + series: { id: "series-1", name: "Pro Series" }, + season: { id: "season-1", seasonName: "Season 1" }, + }, + }, + { + custId: 2, + displayName: "Bob", + finalPoints: 40, + finishPosition: 2, + raceSession: { + series: { id: "series-2", name: "Amateur Series" }, + season: { id: "season-2", seasonName: "Season 1" }, + }, + }, + ]); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + bySeriesSeason: Array<{ seriesId: string }>; + }; + + // sorted by seriesName alphabetically: Amateur → Pro + expect(payload.bySeriesSeason).toHaveLength(2); + expect(payload.bySeriesSeason[0].seriesId).toBe("series-2"); + expect(payload.bySeriesSeason[1].seriesId).toBe("series-1"); + }); +}); diff --git a/src/app/api/leagues/[leagueId]/standings/route.ts b/src/app/api/leagues/[leagueId]/standings/route.ts index 8dcfab5..4fd1d98 100644 --- a/src/app/api/leagues/[leagueId]/standings/route.ts +++ b/src/app/api/leagues/[leagueId]/standings/route.ts @@ -1,6 +1,5 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; interface StandingEntry { custId: number; @@ -92,16 +91,11 @@ function buildStandings( } export async function GET( - request: NextRequest, + _request: Request, { params }: { params: Promise<{ leagueId: string }> }, ) { const { leagueId: rawLeagueId } = await params; - const accessToken = request.cookies.get("irh_access_token")?.value; - if (!accessToken) { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); - } - const iracingLeagueIdNum = parseInt(rawLeagueId, 10); const league = isNaN(iracingLeagueIdNum) ? await prisma.league.findUnique({ @@ -117,23 +111,6 @@ export async function GET( return NextResponse.json({ error: "league_not_found" }, { status: 404 }); } - const iracingCustId = getIracingCustIdFromJwt(accessToken); - const user = await prisma.user.findUnique({ - where: { iracingCustId }, - select: { id: true }, - }); - if (!user) { - return NextResponse.json({ error: "user_not_found" }, { status: 404 }); - } - - const membership = await prisma.leagueMembership.findUnique({ - where: { userId_leagueId: { userId: user.id, leagueId: league.id } }, - select: { id: true }, - }); - if (!membership) { - return NextResponse.json({ error: "not_a_member" }, { status: 403 }); - } - const results = await prisma.raceSessionResult.findMany({ where: { raceSession: { diff --git a/src/app/app/[leagueId]/calendar/page.tsx b/src/app/app/[leagueId]/calendar/page.tsx index 9631f28..e49b490 100644 --- a/src/app/app/[leagueId]/calendar/page.tsx +++ b/src/app/app/[leagueId]/calendar/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "@/components/AuthProvider"; @@ -956,8 +956,7 @@ function EventCard({ // ─── Page ───────────────────────────────────────────────────────────────────── export default function CalendarPage() { - const { session: authSession, loading: authLoading } = useAuth(); - const router = useRouter(); + const { session: authSession } = useAuth(); const searchParams = useSearchParams(); const params = useParams<{ leagueId: string }>(); @@ -974,16 +973,9 @@ export default function CalendarPage() { const triggerReload = useCallback(() => setReloadToken((t) => t + 1), []); - useEffect(() => { - if (!authLoading && !authSession?.authenticated) { - router.replace("/"); - } - }, [authLoading, authSession, router]); - // Load is handled by the useEffect below — no separate callback needed useEffect(() => { - if (!authSession?.authenticated) return; let cancelled = false; async function run() { setLoading(true); @@ -1034,14 +1026,9 @@ export default function CalendarPage() { return () => { cancelled = true; }; - }, [ - authSession?.authenticated, - params.leagueId, - reloadToken, - requestedSeriesId, - ]); - - if (authLoading || loading) { + }, [params.leagueId, reloadToken, requestedSeriesId]); + + if (loading) { return (
{error}
- ← Back to Dashboard + {isAuthenticated ? "← Back to Dashboard" : "← Back Home"}- Team -
-- View all teams with driver car numbers and create your own - team. -
- + {isAuthenticated && ( + ++ Team +
++ View all teams with driver car numbers and create your + own team. +
+ + )} {data.canSelfRegister ? "Registration updates instantly for this event." - : "Your member profile has not been synced yet, so self-registration is unavailable."} + : isAuthenticated + ? "Your member profile has not been synced yet, so self-registration is unavailable." + : "Sign in and join this league to register for races."}