A review of the previous two commits found five defects in the new ledger writer and backfill, plus one pre-existing scoring bug the refactor exposed. season_standings ties were never split. processSeasonStandings deliberately writes the same finalPosition to every driver in a tied group -- its comment says "the scoring system will handle averaging" -- but no path ever did, so two drivers tied for 3rd each banked the full 50 instead of the published 45. This predates the tie-split work; the original cascade had only bracket and qualifying_points arms. Introduce usesSharedPlacementSplit as the single definition of which patterns record ties as a repeated placement, and route both calculatePickPoints and every caller-side gate through it. The caller gates matter as much as the helper: a gate left hardcoded to qualifying_points silently passes a tie count of 1, which reads as "no tie" and makes the fix inert. The ledger anchor picked the wrong event. Ordering on completedAt with no isComplete filter ranked never-completed events first, because drizzle's desc() emits a bare desc and Postgres orders DESC as NULLS FIRST. Restrict to completed events and order explicitly with NULLS LAST plus a stable tiebreak. The anchor is also no longer load-bearing for idempotence: stale event-level rows for the sports season are cleared before writing, so a re-run whose anchor moved replaces rather than duplicates. A ledger failure could abort finalization. The call sat unguarded after the season was already marked completed, so a throw in any of its queries would skip the standings recalculation and the Discord notification. Guard both call sites the way the probability refresh directly below already is. Rows could be mislabelled permanently. The backfill passed no eventName, and the upsert never rewrote scoringEventName. Derive the label from the scoring pattern inside the writer so omitting it is impossible, and refresh it on conflict so existing rows can be repaired. The backfill damaged unrelated leagues. recalculateStandings rewrites previousRank, so sweeping every season wiped rank-movement arrows league wide, including leagues holding no tie at all. Scope it to seasons drafting from a sports season that actually contains a tied placement, and correct the docblock that called it a pure recompute. Also drops the inert Number.EPSILON guard from calculateAveragedPoints (EPSILON is below the ULP for any value >= 2, and integer averages landing on .5 are exactly representable) and extracts countSharedPlacements so the ledger writer stops re-querying rows it already holds. Every fix is covered by a test confirmed to fail when that fix alone is reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
621 lines
21 KiB
TypeScript
621 lines
21 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("~/lib/logger", () => ({
|
|
logger: { error: vi.fn(), warn: vi.fn() },
|
|
}));
|
|
|
|
// ── DB mock helpers ────────────────────────────────────────────────────────
|
|
|
|
function makeInsertChain() {
|
|
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
|
|
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
|
|
const insert = vi.fn().mockReturnValue({ values });
|
|
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
|
const del = vi.fn().mockReturnValue({ where: deleteWhere });
|
|
return { insert, values, onConflictDoUpdate, delete: del, deleteWhere };
|
|
}
|
|
|
|
interface MakeDbOpts {
|
|
sportsSeason?: { sport: { name: string } } | null;
|
|
seasonSports?: { seasonId: string }[];
|
|
picks?: { teamId: string; seasonId: string; participantId?: string }[];
|
|
scoringEvent?: { id: string; name: string } | null;
|
|
participantResults?: {
|
|
participantId: string;
|
|
finalPosition: number | null;
|
|
sportsSeasonId?: string;
|
|
}[];
|
|
seasons?: {
|
|
id: string;
|
|
pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number;
|
|
pointsFor4th: number; pointsFor5th: number; pointsFor6th: number;
|
|
pointsFor7th: number; pointsFor8th: number;
|
|
}[];
|
|
scoreEventRows?: {
|
|
id: string; teamId: string; teamName?: string;
|
|
scoringEventId: string | null; scoringEventName: string | null;
|
|
sportName: string | null; pointsDelta: string; occurredAt: Date;
|
|
participantIds: string[];
|
|
team: { id: string; name: string };
|
|
}[];
|
|
participantRows?: { id: string; name: string }[];
|
|
}
|
|
|
|
function makeDb(opts: MakeDbOpts = {}) {
|
|
const {
|
|
sportsSeason = null,
|
|
seasonSports = [],
|
|
picks = [],
|
|
seasons = [],
|
|
scoreEventRows = [],
|
|
participantRows = [],
|
|
scoringEvent = null,
|
|
participantResults = [],
|
|
} = opts;
|
|
|
|
const chain = makeInsertChain();
|
|
|
|
return {
|
|
db: {
|
|
insert: chain.insert,
|
|
delete: chain.delete,
|
|
query: {
|
|
sportsSeasons: {
|
|
findFirst: vi.fn().mockResolvedValue(sportsSeason),
|
|
},
|
|
seasonSports: {
|
|
findMany: vi.fn().mockResolvedValue(seasonSports),
|
|
},
|
|
draftPicks: {
|
|
findMany: vi.fn().mockResolvedValue(picks),
|
|
},
|
|
seasons: {
|
|
findMany: vi.fn().mockResolvedValue(seasons),
|
|
},
|
|
teamScoreEvents: {
|
|
findMany: vi.fn().mockResolvedValue(scoreEventRows),
|
|
},
|
|
seasonParticipants: {
|
|
findMany: vi.fn().mockResolvedValue(participantRows),
|
|
},
|
|
scoringEvents: {
|
|
findFirst: vi.fn().mockResolvedValue(scoringEvent),
|
|
// Every event in the sports season, used to scope the pre-write delete.
|
|
findMany: vi.fn().mockResolvedValue(scoringEvent ? [{ id: scoringEvent.id }] : []),
|
|
},
|
|
seasonParticipantResults: {
|
|
findMany: vi.fn().mockResolvedValue(participantResults),
|
|
},
|
|
},
|
|
} as any,
|
|
chain,
|
|
};
|
|
}
|
|
|
|
import {
|
|
recordTeamScoreEvent,
|
|
recordMatchScoreEvents,
|
|
recordFinalPlacementScoreEvents,
|
|
getRecentTeamScoreEvents,
|
|
} from "../team-score-events";
|
|
|
|
const BASE_PARAMS = {
|
|
teamId: "team-1",
|
|
seasonId: "season-1",
|
|
scoringEventId: "event-1",
|
|
scoringEventName: "Round of 16",
|
|
sportName: "Darts",
|
|
participantIds: ["p-1"],
|
|
pointsDelta: 10,
|
|
};
|
|
|
|
describe("recordTeamScoreEvent", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("uses matchId-based conflict target when matchId provided", async () => {
|
|
const { db, chain } = makeDb();
|
|
|
|
await recordTeamScoreEvent({ ...BASE_PARAMS, matchId: "match-1" }, db);
|
|
|
|
expect(chain.insert).toHaveBeenCalled();
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ matchId: "match-1", pointsDelta: "10" })
|
|
);
|
|
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
|
|
expect(conflictArg.target).toHaveLength(3);
|
|
// targetWhere should reference matchId IS NOT NULL
|
|
expect(conflictArg.targetWhere).toBeDefined();
|
|
expect(conflictArg.set).toMatchObject({ pointsDelta: "10", participantIds: ["p-1"] });
|
|
});
|
|
|
|
it("uses scoringEventId-based conflict target when no matchId", async () => {
|
|
const { db, chain } = makeDb();
|
|
|
|
await recordTeamScoreEvent(BASE_PARAMS, db);
|
|
|
|
expect(chain.insert).toHaveBeenCalled();
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ matchId: null })
|
|
);
|
|
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
|
|
expect(conflictArg.target).toHaveLength(3);
|
|
expect(conflictArg.targetWhere).toBeDefined();
|
|
});
|
|
|
|
it("stores pointsDelta as string", async () => {
|
|
const { db, chain } = makeDb();
|
|
await recordTeamScoreEvent({ ...BASE_PARAMS, pointsDelta: 42 }, db);
|
|
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ pointsDelta: "42" }));
|
|
});
|
|
|
|
it("defaults matchId to null when not provided", async () => {
|
|
const { db, chain } = makeDb();
|
|
await recordTeamScoreEvent(BASE_PARAMS, db);
|
|
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ matchId: null }));
|
|
});
|
|
});
|
|
|
|
describe("recordMatchScoreEvents", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
const BASE_EVENT = {
|
|
participantId: "p-1",
|
|
sportsSeasonId: "ss-1",
|
|
oldFloor: 0,
|
|
newFloor: 2,
|
|
bracketTemplateId: null as string | null,
|
|
matchId: "match-1",
|
|
eventId: "event-1",
|
|
eventName: "Semifinals",
|
|
};
|
|
|
|
const SEASON_ROW = {
|
|
id: "season-1",
|
|
pointsFor1st: 100, pointsFor2nd: 75, pointsFor3rd: 50,
|
|
pointsFor4th: 40, pointsFor5th: 30, pointsFor6th: 20,
|
|
pointsFor7th: 10, pointsFor8th: 5,
|
|
};
|
|
|
|
it("returns early when no fantasy seasons use this sports season", async () => {
|
|
const { db, chain } = makeDb({
|
|
sportsSeason: { sport: { name: "Darts" } },
|
|
seasonSports: [],
|
|
});
|
|
|
|
await recordMatchScoreEvents(BASE_EVENT, db);
|
|
|
|
expect(chain.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns early when no draft picks found for participant", async () => {
|
|
const { db, chain } = makeDb({
|
|
sportsSeason: { sport: { name: "Darts" } },
|
|
seasonSports: [{ seasonId: "season-1" }],
|
|
picks: [],
|
|
seasons: [SEASON_ROW],
|
|
});
|
|
|
|
await recordMatchScoreEvents(BASE_EVENT, db);
|
|
|
|
expect(chain.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips seasons where team did not draft the participant", async () => {
|
|
const { db, chain } = makeDb({
|
|
sportsSeason: { sport: { name: "Darts" } },
|
|
seasonSports: [{ seasonId: "season-1" }, { seasonId: "season-2" }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1" }], // only season-1 has pick
|
|
seasons: [SEASON_ROW, { ...SEASON_ROW, id: "season-2" }],
|
|
});
|
|
|
|
await recordMatchScoreEvents(BASE_EVENT, db);
|
|
|
|
expect(chain.insert).toHaveBeenCalledTimes(1);
|
|
expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ seasonId: "season-1" }));
|
|
});
|
|
|
|
it("skips seasons where point delta is zero or negative", async () => {
|
|
const { db, chain } = makeDb({
|
|
sportsSeason: { sport: { name: "Darts" } },
|
|
seasonSports: [{ seasonId: "season-1" }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1" }],
|
|
seasons: [{ ...SEASON_ROW, pointsFor1st: 0, pointsFor2nd: 0 }], // all zeros → delta=0
|
|
});
|
|
|
|
// With all-zero scoring rules, positions map to 0 points → delta = 0 → skip
|
|
await recordMatchScoreEvents({ ...BASE_EVENT, oldFloor: 1, newFloor: 2 }, db);
|
|
|
|
expect(chain.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records score event with correct teamId, seasonId, and matchId", async () => {
|
|
const { db, chain } = makeDb({
|
|
sportsSeason: { sport: { name: "Darts" } },
|
|
seasonSports: [{ seasonId: "season-1" }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1" }],
|
|
seasons: [SEASON_ROW],
|
|
});
|
|
|
|
await recordMatchScoreEvents(BASE_EVENT, db);
|
|
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamId: "team-1",
|
|
seasonId: "season-1",
|
|
matchId: "match-1",
|
|
sportName: "Darts",
|
|
participantIds: ["p-1"],
|
|
})
|
|
);
|
|
});
|
|
|
|
it("uses sport name from sportsSeasons query", async () => {
|
|
const { db, chain } = makeDb({
|
|
sportsSeason: { sport: { name: "Snooker" } },
|
|
seasonSports: [{ seasonId: "season-1" }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1" }],
|
|
seasons: [SEASON_ROW],
|
|
});
|
|
|
|
await recordMatchScoreEvents(BASE_EVENT, db);
|
|
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sportName: "Snooker" })
|
|
);
|
|
});
|
|
|
|
it("handles null sportsSeason gracefully (sportName = null)", async () => {
|
|
const { db, chain } = makeDb({
|
|
sportsSeason: null,
|
|
seasonSports: [{ seasonId: "season-1" }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1" }],
|
|
seasons: [SEASON_ROW],
|
|
});
|
|
|
|
await recordMatchScoreEvents(BASE_EVENT, db);
|
|
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sportName: null })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getRecentTeamScoreEvents", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("returns empty array when no rows found", async () => {
|
|
const { db } = makeDb({ scoreEventRows: [] });
|
|
const result = await getRecentTeamScoreEvents("season-1", 10, db);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("does not query participants when rows array is empty", async () => {
|
|
const { db } = makeDb({ scoreEventRows: [] });
|
|
await getRecentTeamScoreEvents("season-1", 10, db);
|
|
expect(db.query.seasonParticipants.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns mapped entries with resolved participant names", async () => {
|
|
const occurredAt = new Date("2024-01-15");
|
|
const { db } = makeDb({
|
|
scoreEventRows: [
|
|
{
|
|
id: "tse-1",
|
|
teamId: "team-1",
|
|
scoringEventId: "event-1",
|
|
scoringEventName: "Semifinals",
|
|
sportName: "Darts",
|
|
pointsDelta: "75",
|
|
occurredAt,
|
|
participantIds: ["p-1", "p-2"],
|
|
team: { id: "team-1", name: "Team Alpha" },
|
|
},
|
|
],
|
|
participantRows: [
|
|
{ id: "p-1", name: "Luke Littler" },
|
|
{ id: "p-2", name: "Michael van Gerwen" },
|
|
],
|
|
});
|
|
|
|
const result = await getRecentTeamScoreEvents("season-1", 10, db);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toMatchObject({
|
|
id: "tse-1",
|
|
teamId: "team-1",
|
|
teamName: "Team Alpha",
|
|
scoringEventId: "event-1",
|
|
scoringEventName: "Semifinals",
|
|
sportName: "Darts",
|
|
pointsDelta: "75",
|
|
occurredAt,
|
|
});
|
|
expect(result[0].participants).toEqual([
|
|
{ id: "p-1", name: "Luke Littler" },
|
|
{ id: "p-2", name: "Michael van Gerwen" },
|
|
]);
|
|
});
|
|
|
|
it("omits participants whose names cannot be resolved", async () => {
|
|
const { db } = makeDb({
|
|
scoreEventRows: [
|
|
{
|
|
id: "tse-1",
|
|
teamId: "team-1",
|
|
scoringEventId: "event-1",
|
|
scoringEventName: null,
|
|
sportName: null,
|
|
pointsDelta: "50",
|
|
occurredAt: new Date(),
|
|
participantIds: ["p-1", "p-missing"],
|
|
team: { id: "team-1", name: "Team Beta" },
|
|
},
|
|
],
|
|
participantRows: [{ id: "p-1", name: "Known Player" }],
|
|
});
|
|
|
|
const result = await getRecentTeamScoreEvents("season-1", 10, db);
|
|
|
|
expect(result[0].participants).toEqual([{ id: "p-1", name: "Known Player" }]);
|
|
});
|
|
|
|
it("handles null participantIds gracefully", async () => {
|
|
const { db } = makeDb({
|
|
scoreEventRows: [
|
|
{
|
|
id: "tse-1",
|
|
teamId: "team-1",
|
|
scoringEventId: "event-1",
|
|
scoringEventName: null,
|
|
sportName: null,
|
|
pointsDelta: "10",
|
|
occurredAt: new Date(),
|
|
participantIds: null as any,
|
|
team: { id: "team-1", name: "Team Gamma" },
|
|
},
|
|
],
|
|
participantRows: [],
|
|
});
|
|
|
|
const result = await getRecentTeamScoreEvents("season-1", 10, db);
|
|
|
|
expect(result[0].participants).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("recordFinalPlacementScoreEvents", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
const RULES = {
|
|
id: "season-1",
|
|
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40,
|
|
pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15,
|
|
};
|
|
|
|
function makeQpDb(overrides: Partial<MakeDbOpts> = {}) {
|
|
return makeDb({
|
|
sportsSeason: { scoringPattern: "qualifying_points", sport: { name: "Golf" } } as any,
|
|
scoringEvent: { id: "event-9", name: "The Open" },
|
|
seasonSports: [{ seasonId: "season-1" }],
|
|
seasons: [RULES],
|
|
...overrides,
|
|
// Tie counts are grouped by sportsSeasonId, so result rows must carry it.
|
|
participantResults: (overrides.participantResults ?? []).map((r) => ({
|
|
sportsSeasonId: "ss-1",
|
|
...r,
|
|
})),
|
|
});
|
|
}
|
|
|
|
it("writes one row per team with the summed award and its participants", async () => {
|
|
const { db, chain } = makeQpDb({
|
|
participantResults: [
|
|
{ participantId: "rahm", finalPosition: 8 },
|
|
{ participantId: "scheffler", finalPosition: 1 },
|
|
],
|
|
picks: [
|
|
{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" },
|
|
{ teamId: "team-1", seasonId: "season-1", participantId: "scheffler" },
|
|
],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents(
|
|
{ sportsSeasonId: "ss-1", eventName: "Final Standings" },
|
|
db
|
|
);
|
|
|
|
expect(chain.insert).toHaveBeenCalledTimes(1);
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamId: "team-1",
|
|
seasonId: "season-1",
|
|
scoringEventId: "event-9",
|
|
scoringEventName: "Final Standings",
|
|
sportName: "Golf",
|
|
matchId: null,
|
|
pointsDelta: "115", // 100 (1st) + 15 (8th, untied)
|
|
participantIds: ["rahm", "scheffler"],
|
|
})
|
|
);
|
|
});
|
|
|
|
it("awards the split value when the placement is tied", async () => {
|
|
const { db, chain } = makeQpDb({
|
|
// Rahm ties for 8th with an UNDRAFTED player — the tie still halves it.
|
|
participantResults: [
|
|
{ participantId: "rahm", finalPosition: 8 },
|
|
{ participantId: "undrafted-guy", finalPosition: 8 },
|
|
],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
// (15 + 0) / 2 = 7.5 → 8, matching what the standings show.
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ pointsDelta: "8", participantIds: ["rahm"] })
|
|
);
|
|
});
|
|
|
|
it("keeps each team's award separate", async () => {
|
|
const { db, chain } = makeQpDb({
|
|
participantResults: [
|
|
{ participantId: "rahm", finalPosition: 8 },
|
|
{ participantId: "scheffler", finalPosition: 1 },
|
|
],
|
|
picks: [
|
|
{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" },
|
|
{ teamId: "team-2", seasonId: "season-1", participantId: "scheffler" },
|
|
],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
expect(chain.insert).toHaveBeenCalledTimes(2);
|
|
const deltasByTeam = Object.fromEntries(
|
|
chain.values.mock.calls.map(([v]: any[]) => [v.teamId, v.pointsDelta])
|
|
);
|
|
expect(deltasByTeam).toEqual({ "team-1": "15", "team-2": "100" });
|
|
});
|
|
|
|
it("upserts on the event-level target so re-finalizing does not duplicate", async () => {
|
|
const { db, chain } = makeQpDb({
|
|
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
const conflictArg = chain.onConflictDoUpdate.mock.calls[0][0];
|
|
expect(conflictArg.target).toHaveLength(3);
|
|
expect(conflictArg.targetWhere).toBeDefined();
|
|
expect(conflictArg.set).toMatchObject({ pointsDelta: "15" });
|
|
});
|
|
|
|
it("skips the write when no scoring event can anchor the row", async () => {
|
|
// A null scoringEventId would defeat the partial unique index, since
|
|
// Postgres treats NULLs as distinct — better to skip than duplicate.
|
|
const { db, chain } = makeQpDb({
|
|
scoringEvent: null,
|
|
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
expect(chain.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores non-scoring placements", async () => {
|
|
const { db, chain } = makeQpDb({
|
|
participantResults: [
|
|
{ participantId: "rahm", finalPosition: 0 },
|
|
{ participantId: "other", finalPosition: null },
|
|
],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
expect(chain.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scores season_standings placements with the shared tier averaging", async () => {
|
|
const { db, chain } = makeQpDb({
|
|
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
|
|
participantResults: [{ participantId: "driver", finalPosition: 3 }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sportName: "F1", pointsDelta: "50" })
|
|
);
|
|
});
|
|
|
|
it("splits a tied season_standings placement", async () => {
|
|
// processSeasonStandings gives a tied group the same finalPosition and leaves
|
|
// the split to scoring time. Two drivers tied at 3rd share 3rd + 4th:
|
|
// (50 + 40) / 2 = 45.
|
|
const { db, chain } = makeQpDb({
|
|
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
|
|
participantResults: [
|
|
{ participantId: "driver", finalPosition: 3 },
|
|
{ participantId: "other-driver", finalPosition: 3 },
|
|
],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
expect(chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ pointsDelta: "45" })
|
|
);
|
|
});
|
|
|
|
it("anchors to a completed event rather than an incomplete one", async () => {
|
|
// Postgres orders DESC as NULLS FIRST and drizzle's desc() cannot express
|
|
// NULLS LAST, so an incomplete event (completedAt NULL) would otherwise win
|
|
// the anchor. The query must constrain isComplete itself.
|
|
const { db } = makeQpDb({
|
|
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
const anchorQuery = db.query.scoringEvents.findFirst.mock.calls[0][0];
|
|
expect(anchorQuery.where).toBeDefined();
|
|
// Ordering must not be a bare desc() on completedAt alone.
|
|
expect(anchorQuery.orderBy.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("clears the season's existing rows before writing so a moved anchor cannot duplicate", async () => {
|
|
const { db, chain } = makeQpDb({
|
|
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
expect(chain.delete).toHaveBeenCalledTimes(1);
|
|
expect(chain.deleteWhere).toHaveBeenCalledTimes(1);
|
|
expect(chain.insert).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("labels rows from the scoring pattern with no caller input", async () => {
|
|
const qp = makeQpDb({
|
|
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
|
});
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, qp.db);
|
|
expect(qp.chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ scoringEventName: "Final Standings" })
|
|
);
|
|
|
|
const f1 = makeQpDb({
|
|
sportsSeason: { scoringPattern: "season_standings", sport: { name: "F1" } } as any,
|
|
participantResults: [{ participantId: "driver", finalPosition: 3 }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "driver" }],
|
|
});
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, f1.db);
|
|
expect(f1.chain.values).toHaveBeenCalledWith(
|
|
expect.objectContaining({ scoringEventName: "Season Complete" })
|
|
);
|
|
});
|
|
|
|
it("rewrites the label on conflict so a stale row can be repaired", async () => {
|
|
const { db, chain } = makeQpDb({
|
|
participantResults: [{ participantId: "rahm", finalPosition: 8 }],
|
|
picks: [{ teamId: "team-1", seasonId: "season-1", participantId: "rahm" }],
|
|
});
|
|
|
|
await recordFinalPlacementScoreEvents({ sportsSeasonId: "ss-1" }, db);
|
|
|
|
expect(chain.onConflictDoUpdate.mock.calls[0][0].set).toMatchObject({
|
|
scoringEventName: "Final Standings",
|
|
sportName: "Golf",
|
|
});
|
|
});
|
|
});
|