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
21 changes: 21 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Quality

on:
pull_request:
push:
branches: [main]

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.12.0
cache: npm
- run: npm ci
- run: npm test
- run: npm run lint
- run: npm run build
36 changes: 25 additions & 11 deletions audit/dimension-1/domain.audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,28 @@ function fixture(): ScoreInput {
}

describe("dimension 1: known domain defects at audit baseline", () => {
it.fails("F04: persistent ownership remains authoritative after shortlisting", () => {
it("F04: persistent ownership remains authoritative after shortlisting", () => {
const input = fixture();
input.sessionGames[0].signals = [];
expect(scoreSessionGames(input)[0].ownership.have).toBe(2);
});

it.fails("F07: a veto prevents the perfect category", () => {
it("F07: a veto prevents the perfect category", () => {
const input = fixture();
input.sessionGames[0].interests = [{ participantId: "p2", interest: "NOT_TONIGHT" }];
const result = scoreSessionGames(input)[0];
expect(result.alignment).toBe("Low");
expect(result.categories).not.toContain("perfect");
});

it.fails("F06: an unknown upper player limit cannot confirm a 50-player fit", () => {
it("F06: an unknown upper player limit cannot confirm a 50-player fit", () => {
const input = fixture();
input.playerCount = 50;
input.sessionGames[0].game.maxPlayers = null;
expect(scoreSessionGames(input)[0].playerCountStatus).toBe("uncertain");
});

it.fails("F07: unknown platform for one selected owner prevents confirmed same-platform fit", () => {
it("F07: unknown platform for one selected owner prevents confirmed same-platform fit", () => {
const input = fixture();
input.participants.push({ id: "p3", userId: "u3" });
input.selectedParticipantIds!.push("p3");
Expand All @@ -61,7 +61,7 @@ describe("dimension 1: known domain defects at audit baseline", () => {
expect(scoreSessionGames(input)[0].platformFit).toBe("unknown");
});

it.fails("F07: a 2-player game is not perfect for four selected participants", () => {
it("F07: a 2-player game is not perfect for four selected participants", () => {
const input = fixture();
input.sessionGames[0].game.maxPlayers = 2;
for (const n of [3, 4]) {
Expand All @@ -72,16 +72,17 @@ describe("dimension 1: known domain defects at audit baseline", () => {
expect(scoreSessionGames(input).some((game) => game.categories.includes("perfect"))).toBe(false);
});

it.fails("F06: Discovery and Pick agree on the same modded 5-player candidate", () => {
it("F06: Discovery and Pick agree on the same modded 5-player candidate", () => {
const curated = getCuratedGame("subnautica")!;
expect(supportsAtLeast(curated, 5)).toBe(true);
const input = fixture();
input.playerCount = 5;
input.sessionGames[0].game = { ...curated, id: "g1" };
input.setup = "modded";
expect(scoreSessionGames(input)).toHaveLength(1);
});

it.fails("F07: tied scores have an identity-based total order independent of input order", () => {
it("F07: tied scores have an identity-based total order independent of input order", () => {
const input = fixture();
input.sessionGames.push({ ...input.sessionGames[0], id: "sg2", gameId: "g2", game: { ...input.sessionGames[0].game, id: "g2" } });
input.userGames = [];
Expand All @@ -90,23 +91,36 @@ describe("dimension 1: known domain defects at audit baseline", () => {
expect(first).toEqual(second);
});

it.fails("F10: a budget constraint excludes known over-budget group buys", () => {
it("F10: a budget constraint excludes known over-budget group buys", () => {
const deals = new Map(curatedGames.map((game) => [game.title, { currentPrice: 5000, currency: "GBP", discountPercent: 0 }]));
const result = scoreGroupBuyCandidates({ filters: { ...defaultGroupBuyFilters(2), budget: 100, avoidOwned: false }, ownedTitles: [], deals });
expect(result.filter((r) => r.price !== null && r.price !== undefined && r.price > 100)).toHaveLength(0);
});

it.fails("F10: a one-night-only request cannot manufacture a long-term section", () => {
it("F10: a one-night-only request cannot manufacture a long-term section", () => {
const result = scoreGroupBuyCandidates({ filters: { ...defaultGroupBuyFilters(2), sessionLength: "one-night", avoidOwned: false }, ownedTitles: [], deals: new Map() });
expect(result.filter((r) => r.section === "longTerm")).toHaveLength(0);
});

it.fails("F11: spring DST produces unique real hourly instants", () => {
it("F10: multi-token group-buy genres use an explicit all-token match", () => {
const result = scoreGroupBuyCandidates({
filters: { ...defaultGroupBuyFilters(4), genre: "co-op survival", avoidOwned: false },
ownedTitles: [],
deals: new Map(),
});
expect(result.length).toBeGreaterThan(0);
expect(result.every((recommendation) => {
const tags = recommendation.game.tags.map((tag) => tag.replaceAll("co-op", "coop"));
return tags.some((tag) => tag.includes("coop")) && tags.some((tag) => tag.includes("survival"));
})).toBe(true);
});

it("F11: spring DST produces unique real hourly instants", () => {
const slots = generateHourlySlots({ dateRangeStart: "2026-03-29", dateRangeEnd: "2026-03-29", dailyStartHour: 0, dailyEndHour: 4, requiredDuration: 2, timezone: "Europe/London" });
expect(new Set(slots.map((s) => s.startsAt.toISOString())).size).toBe(slots.length);
});

it.fails("F11: autumn DST candidates contain adjacent real hours", () => {
it("F11: autumn DST candidates contain adjacent real hours", () => {
const windows = generateCandidateWindows({ dateRangeStart: "2026-10-25", dateRangeEnd: "2026-10-25", dailyStartHour: 0, dailyEndHour: 4, requiredDuration: 2, timezone: "Europe/London" });
expect(windows.every((w) => w.endsAt.getTime() - w.startsAt.getTime() === 2 * 60 * 60 * 1000)).toBe(true);
});
Expand Down
6 changes: 3 additions & 3 deletions audit/dimension-1/identity.audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ vi.mock("@/lib/prisma", () => ({ prisma: {
beforeEach(() => { mocks.values.clear(); vi.clearAllMocks(); });

describe("dimension 1: known identity defects at audit baseline", () => {
it.fails("F01: ordinary participant refresh preserves an anonymous host grant", async () => {
it("F01: ordinary participant refresh preserves an anonymous host grant", async () => {
await setParticipantIdentity("s1", "host1", { isHost: true });
expect(await getHostParticipantId("s1")).toBe("host1");
// submitAvailabilityAction calls this without options after saving the name.
await setParticipantIdentity("s1", "host1");
expect(await getHostParticipantId("s1")).toBe("host1");
});

it.fails("F02: stale cookie cannot override the signed-in account's supplied membership", async () => {
it("F02: stale cookie cannot override the signed-in account's supplied membership", async () => {
await setParticipantIdentity("s1", "old-user-participant");
mocks.values.set("lpg_session", signValue("new-user-session-token"));
mocks.userSessionFindFirst.mockResolvedValue({ user: { id: "new-user" } });
Expand All @@ -45,7 +45,7 @@ describe("dimension 1: known identity defects at audit baseline", () => {
expect(await getHostParticipantId("s1")).toBe("host1");
});

it.fails("F15: redirects remain same-origin after browser URL normalization", () => {
it("F15: redirects remain same-origin after browser URL normalization", () => {
const destination = safeInternalRedirect("/\\example.invalid/path");
expect(new URL(destination, "https://app.invalid").origin).toBe("https://app.invalid");
});
Expand Down
17 changes: 9 additions & 8 deletions audit/dimension-1/mutations.audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,38 +41,39 @@ beforeEach(() => {
});

describe("dimension 1: known mutation defects at audit baseline", () => {
it.fails("F05: suggesting a game does not write persistent ownership", async () => {
it("F05: suggesting a game does not write persistent ownership", async () => {
const form = new FormData();
Object.entries({ shareToken: "share", participantId: "p1", title: "Suggested title", gameId: "g1", source: "MANUAL" }).forEach(([k, v]) => form.set(k, v));
await addSessionGameAction(form);
expect(mocks.sessionGameUpsert).toHaveBeenCalledOnce();
expect(mocks.userGameUpsert).not.toHaveBeenCalled();
});

it.fails("F12: a title-only add does not overwrite known co-op metadata with false", async () => {
it("F12: a title-only add does not overwrite known co-op metadata with false", async () => {
await upsertGame({ title: "Known title" });
expect(mocks.gameUpdate).toHaveBeenCalledOnce();
expect(mocks.gameUpdate.mock.calls[0][0].data.onlineCoop).toBeUndefined();
});

it.fails("F12: generic multiplayer metadata is insufficient to assert co-op", async () => {
it("F12: generic multiplayer metadata is insufficient to assert co-op", async () => {
await upsertGame({ title: "Synthetic versus game", steamAppId: 123, gameModes: ["Multiplayer"] });
expect(mocks.gameUpsert).toHaveBeenCalledOnce();
expect(mocks.gameUpsert.mock.calls[0][0].create.onlineCoop).not.toBe(true);
});

it.fails("F14: clearing a rating writes null, not an omitted update", async () => {
it("F14: clearing a rating writes null, not an omitted update", async () => {
const form = new FormData();
Object.entries({ gameId: "g1", ownership: "HAVE", rating: "", notes: "", interest: "NEUTRAL", playedStatus: "UNPLAYED" }).forEach(([k, v]) => form.set(k, v));
await updateLibraryGameAction(form);
expect(mocks.userGameUpsert).toHaveBeenCalledOnce();
expect(mocks.userGameUpsert.mock.calls[0][0].update.rating).toBeNull();
});

it("F05 evidence: an absent curated Steam row is rejected before curated fallback", async () => {
it("F05: a curated Steam game can be shortlisted before it has been imported", async () => {
const form = new FormData();
Object.entries({ shareToken: "share", participantId: "p1", title: "Portal 2", steamAppId: "620", source: "COMMON" }).forEach(([k, v]) => form.set(k, v));
await expect(addSessionGameAction(form)).rejects.toThrow("Import or search for that Steam game before adding it.");
expect(mocks.sessionGameUpsert).not.toHaveBeenCalled();
Object.entries({ shareToken: "share", participantId: "p1", title: "PEAK", steamAppId: "3527290", source: "COMMON" }).forEach(([k, v]) => form.set(k, v));
await expect(addSessionGameAction(form)).resolves.toBeUndefined();
expect(mocks.gameUpsert).toHaveBeenCalledOnce();
expect(mocks.sessionGameUpsert).toHaveBeenCalledOnce();
});
});
19 changes: 12 additions & 7 deletions audit/dimension-1/session.audit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ vi.mock("@/lib/prisma", () => ({ prisma: {
userFriend: { findMany: async () => [] },
friendGroup: { findMany: async () => [] },
game: { findMany: async () => [] },
userBlock: { findMany: async () => [] },
} }));
vi.mock("@/lib/auth", () => ({
getCurrentUser: async () => ({ id: "viewer" }),
getCurrentUser: async () => ({ id: "u1" }),
getParticipantId: async () => null,
}));
vi.mock("@/lib/accounts", () => ({ requireActivePickUser: async () => ({ id: "viewer" }) }));
vi.mock("@/lib/accounts", () => ({
isActivePickUser: (user: { id: string } | null) => Boolean(user),
onboardingUrl: (returnTo: string) => `/account/onboarding?returnTo=${encodeURIComponent(returnTo)}`,
signInUrl: (returnTo: string) => `/account/sign-in?returnTo=${encodeURIComponent(returnTo)}`,
}));
vi.mock("@/lib/app-url", () => ({ getAppUrl: async () => "https://audit.invalid" }));
vi.mock("@/lib/igdb", () => ({ getPopularIgdbGames: async () => [], getTrendingIgdbGames: async () => [], mapIgdbGame: vi.fn() }));
vi.mock("@/components/pick-panel", () => ({ PickPanel: () => null }));
Expand Down Expand Up @@ -58,26 +63,26 @@ async function compose(query: Record<string, string | string[]> = {}) {
}

describe("dimension 1: real session page composition", () => {
it.fails("F04: ownership uses complete rows even when a candidate owner's discovery row is cut off", async () => {
it("F04: ownership uses complete rows even when a candidate owner's discovery row is cut off", async () => {
const panel = await compose();
expect(mocks.userGames).toHaveBeenCalledTimes(2);
expect(panel.scoredGames).toHaveLength(1);
expect(panel.scoredGames[0].ownership.have).toBe(2);
});

it.fails("F09: repeated and foreign selected participant IDs are canonicalized", async () => {
it("F09: repeated and foreign selected participant IDs are canonicalized", async () => {
const panel = await compose({ selectedParticipants: ["p1", "p1", "foreign"] });
expect(panel.selectedParticipantIds).toEqual(["p1"]);
});

it.fails("F09: non-finite player counts do not enter matching", async () => {
it("F09: non-finite player counts do not enter matching", async () => {
const panel = await compose({ playerCount: "Infinity" });
expect(Number.isFinite(panel.selectedPlayerCount)).toBe(true);
});

it("F03/F16 evidence: a signed-in non-member receives full matching output without joining", async () => {
it("F03/F16: private matching is available only to joined account members", async () => {
const panel = await compose();
expect(panel.participantId).toBeUndefined();
expect(panel.participantId).toBe("p1");
expect(panel.scoredGames[0].title).toBe(game.title);
});
});
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"test:integration:dimension1": "tsx scripts/test-dimension1-integration.ts",
"verify": "npm test && npm run lint && npm run build",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"discord:commands": "tsx scripts/register-discord-commands.ts",
"catalog:duplicate-report": "tsx scripts/report-game-duplicates.ts",
"challenges:seed": "tsx scripts/seed-challenges.ts",
"db:setup": "powershell -ExecutionPolicy Bypass -File scripts/setup-local-db.ps1",
"test:all": "powershell -ExecutionPolicy Bypass -File scripts/test-all.ps1"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
ALTER TABLE "Participant"
ADD COLUMN "availabilityRevision" INTEGER NOT NULL DEFAULT 0;

ALTER TABLE "Participant"
ADD COLUMN "historyVisible" BOOLEAN NOT NULL DEFAULT true;

CREATE TABLE "DiscordHostHandoff" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"discordUserId" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"participantId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"redeemedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DiscordHostHandoff_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "DiscordHostHandoff_tokenHash_key" ON "DiscordHostHandoff"("tokenHash");
CREATE INDEX "DiscordHostHandoff_discordUserId_expiresAt_idx" ON "DiscordHostHandoff"("discordUserId", "expiresAt");
CREATE INDEX "DiscordHostHandoff_sessionId_participantId_idx" ON "DiscordHostHandoff"("sessionId", "participantId");
ALTER TABLE "DiscordHostHandoff" ADD CONSTRAINT "DiscordHostHandoff_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DiscordHostHandoff" ADD CONSTRAINT "DiscordHostHandoff_participantId_fkey" FOREIGN KEY ("participantId") REFERENCES "Participant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
20 changes: 20 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ model Session {
discordIntegrations DiscordIntegration[]
discordNotifications DiscordNotificationLog[]
discordAttendance DiscordAttendance[]
discordHostHandoffs DiscordHostHandoff[]

@@unique([gameNightId, workspaceType])
@@index([gameNightId])
Expand All @@ -194,6 +195,8 @@ model Participant {
discordUserId String?
discordUsername String?
isHost Boolean @default(false)
availabilityRevision Int @default(0)
historyVisible Boolean @default(true)
createdAt DateTime @default(now())
responses AvailabilityResponse[]
addedGames SessionGame[]
Expand All @@ -202,6 +205,7 @@ model Participant {
preference ParticipantPreference?
preferenceNudgeDismissedAt DateTime?
discordAttendance DiscordAttendance[]
discordHostHandoffs DiscordHostHandoff[]

@@unique([sessionId, userId])
@@unique([sessionId, discordUserId])
Expand Down Expand Up @@ -239,6 +243,22 @@ model DiscordIntegration {
@@index([guildId, channelId])
}

model DiscordHostHandoff {
id String @id @default(cuid())
tokenHash String @unique
discordUserId String
sessionId String
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
participantId String
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
expiresAt DateTime
redeemedAt DateTime?
createdAt DateTime @default(now())

@@index([discordUserId, expiresAt])
@@index([sessionId, participantId])
}

model DiscordNotificationLog {
id String @id @default(cuid())
sessionId String
Expand Down
43 changes: 43 additions & 0 deletions scripts/report-game-duplicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/prisma";

async function main() {
const games = await prisma.game.findMany({
select: {
id: true,
title: true,
normalizedTitle: true,
slug: true,
steamAppId: true,
igdbId: true,
itadId: true,
},
orderBy: [{ normalizedTitle: "asc" }, { id: "asc" }],
});

const byTitle = new Map<string, typeof games>();
for (const game of games) {
const group = byTitle.get(game.normalizedTitle) ?? [];
group.push(game);
byTitle.set(game.normalizedTitle, group);
}

const possibleDuplicates = [...byTitle.entries()]
.filter(([, group]) => group.length > 1)
.map(([normalizedTitle, group]) => ({ normalizedTitle, games: group }));

console.log(JSON.stringify({
generatedAt: new Date().toISOString(),
gameCount: games.length,
possibleDuplicateTitleGroups: possibleDuplicates.length,
note: "Title equality is advisory only. Merge only when stable provider identity or reviewed edition evidence proves equivalence.",
possibleDuplicates,
}, null, 2));

await prisma.$disconnect();
}

main().catch(async (error) => {
console.error(error instanceof Error ? error.message : error);
await prisma.$disconnect();
process.exitCode = 1;
});
Loading
Loading