Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
57a6b25
Merge remote-tracking branch 'origin/dev' into fix/hackathon-portal-f…
aamoghS Oct 1, 2026
c968fb3
style(portal): give the hackathon portal the dashboard's look
aamoghS Oct 1, 2026
2a3e458
feat(hackathon): cap acceptances, not applications
aamoghS Oct 1, 2026
108b8ec
feat(teams): let accepted hackers form teams before kickoff
aamoghS Oct 1, 2026
926a13b
feat(judges): approve as any admin, add by email, apply once announced
aamoghS Oct 1, 2026
f5d6b90
feat(portal): tell teams their table; working Add judge form
aamoghS Oct 1, 2026
16c0e59
fix(hackathon): honest states for the public page and judging
aamoghS Oct 1, 2026
7ec127a
feat(hackathon): let participants withdraw; tidy results email
aamoghS Oct 1, 2026
70752f0
style(login): drop the terminal theatre from the sign-in page
aamoghS Oct 1, 2026
48969b8
feat(hackathon): close the remaining participant and organiser gaps
aamoghS Oct 1, 2026
971cba5
style(login): tighten the sign-in layout
aamoghS Oct 1, 2026
a86efc0
Merge pull request #449 from DataScience-GT/dev
aamoghS Oct 1, 2026
2df6d9e
fix(hackathon): close races found in review
aamoghS Oct 1, 2026
750078d
fix(hackathon): registration form and judge picker edge cases
aamoghS Oct 1, 2026
c110a14
style(admin): bring the judging and hackathon admin screens in line
aamoghS Oct 1, 2026
f150c8b
Merge pull request #451 from DataScience-GT/dev
aamoghS Oct 1, 2026
f7f3bd5
Merge remote-tracking branch 'origin/main' into fix/hackathon-portal-…
github-actions[bot] Oct 1, 2026
e790596
Merge pull request #454 from DataScience-GT/dev
aamoghS Oct 1, 2026
023c3a8
Merge remote-tracking branch 'origin/main' into fix/hackathon-portal-…
github-actions[bot] Oct 1, 2026
0342a95
fix(hackathon): address Bugbot findings on the portal branch
aamoghS Oct 1, 2026
739b673
fix(judging): scope the start-judging error to its hackathon
aamoghS Oct 1, 2026
e6bd765
Merge branch 'dev' into fix/hackathon-portal-finish
aamoghS Oct 2, 2026
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
52 changes: 51 additions & 1 deletion packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const mockUpdate = vi.fn();
const mockDelete = vi.fn();
// Row count returned by `select({ count })...from(<table>)`, keyed by table.
const mockCount = vi.fn();
/** maxParticipants as the capacity check reads it; undefined = uncapped. */
const mockMaxParticipants = vi.fn<() => number | undefined>(() => undefined);
// Acceptance emails are sent through a dynamic import; intercept it so we can
// see exactly who a mass-approval actually mailed.
const mockSendAcceptanceEmail = vi.fn();
Expand Down Expand Up @@ -101,7 +103,17 @@ vi.mock("@query/db", () => {
},
}),
select: vi.fn().mockImplementation(() => ({
from: (tbl: any) => thenable([{ count: mockCount(tbl?._t) ?? 0 }]),
from: (tbl: any) => {
const count = mockCount(tbl?._t) ?? 0;
return thenable([
{
count,
n: count,
max:
tbl?._t === "hackathons" ? mockMaxParticipants() : undefined,
},
]);
},
})),
},
admins: {
Expand Down Expand Up @@ -266,6 +278,7 @@ describe("Hackathon admin management edge cases", () => {
vi.clearAllMocks();
cache.clear();
mockCount.mockReturnValue(0);
mockMaxParticipants.mockImplementation(() => undefined);
mockFindMany.mockReturnValue([]);
mockInsert.mockReturnValue([{}]);
mockUpdate.mockReturnValue([{}]);
Expand Down Expand Up @@ -345,6 +358,43 @@ describe("Hackathon admin management edge cases", () => {
).rejects.toThrow(/Admin access required/);
});

// maxParticipants caps acceptances. Two seats, both taken: accepting one
// more is refused whole rather than going over.
it("refuses to accept past capacity", async () => {
const caller = adminCaller({}, "admin");
mockMaxParticipants.mockImplementation(() => 2);
const counts = [2, 1]; // seated, then newly accepted by this call
mockCount.mockImplementation((table: string) =>
table === "hackathonParticipants" ? counts.shift() : 0,
);

await expect(
caller.hackathon.batchUpdateParticipantStatus({
hackathonId: HACK_A,
participantIds: [PART_A1],
status: "approved",
}),
).rejects.toThrow(/Only 0 seats left of 2/);
});

it("accepts while seats remain", async () => {
const caller = adminCaller({}, "admin");
mockMaxParticipants.mockImplementation(() => 2);
const counts = [1, 1];
mockCount.mockImplementation((table: string) =>
table === "hackathonParticipants" ? counts.shift() : 0,
);
mockUpdate.mockReturnValue([{ id: PART_A1, userId: "u1" }]);

await expect(
caller.hackathon.batchUpdateParticipantStatus({
hackathonId: HACK_A,
participantIds: [PART_A1],
status: "approved",
}),
).resolves.toBeDefined();
});

it("lets a volunteer work a check-in desk", async () => {
const caller = volunteerCaller({
hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
Expand Down
55 changes: 40 additions & 15 deletions packages/api/src/.internal-tests/hackathon-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { db } from "@query/db";
// Fully mock the DB at the file level. vi.mock factories are hoisted and
// file-scoped, so this mirrors the shape used by routers.test.ts.
const mockFindFirst = vi.fn();
/** Rows a bare `await select().from().where()` resolves to. */
const mockWhereRows = vi.fn<() => unknown[]>(() => [{ n: 1 }]);
const mockFindMany = vi.fn();
const mockInsert = vi.fn();
const mockUpdate = vi.fn();
Expand Down Expand Up @@ -74,6 +76,9 @@ vi.mock("@query/db", () => {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
// Awaited directly: a count such as the judging queue check.
then: (ok: any, err: any) =>
Promise.resolve(mockWhereRows()).then(ok, err),
orderBy: vi.fn().mockResolvedValue([{ count: 0 }]),
groupBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -305,17 +310,20 @@ describe("Hackathon end-to-end flow", () => {
);
});

it("rejects registration when the hackathon is at capacity", async () => {
// Capacity caps acceptances, not applications: a full house still takes
// applications, and the seat is enforced when an organiser accepts.
it("takes an application when every seat is already taken", async () => {
mockFindFirst.mockImplementation((table) =>
table === "hackathons"
? openHackathon({ maxParticipants: 500, currentParticipants: 500 })
: undefined,
);
const caller = appRouter.createCaller(createMockCtx("user_a"));

await expect(caller.hackathon.register(registrationInput())).rejects.toThrow(
/full/,
);
const outcome = await caller.hackathon
.register(registrationInput())
.catch((error: unknown) => error);
expect(String((outcome as Error)?.message ?? "")).not.toMatch(/full/);
});
});

Expand Down Expand Up @@ -541,6 +549,7 @@ describe("Hackathon end-to-end flow", () => {

it("only lets an admin toggle judging on", async () => {
const caller = adminCaller();
mockWhereRows.mockReturnValueOnce([{ n: 12 }]);
mockUpdate.mockReturnValue([{ judgingActive: true }]);

const res = await caller.judge.toggleJudging({
Expand All @@ -550,6 +559,17 @@ describe("Hackathon end-to-end flow", () => {
expect(res).toMatchObject({ success: true, judgingActive: true });
});

// With nothing queued every judge opened straight onto "All done, 0 of 0".
it("refuses to open judging before any judge has a table", async () => {
const caller = adminCaller();
mockWhereRows.mockReturnValueOnce([{ n: 0 }]);

await expect(
caller.judge.toggleJudging({ hackathonId: HACK_A, active: true }),
).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
expect(mockUpdate).not.toHaveBeenCalled();
});

it("refuses to let a non-admin toggle judging", async () => {
mockFindFirst.mockImplementation(() => undefined);
const caller = appRouter.createCaller(createMockCtx("random_user"));
Expand Down Expand Up @@ -604,12 +624,14 @@ describe("Hackathon end-to-end flow", () => {
maxMembers: 4 as const,
};

it("refuses team creation before the window opens at +12h", async () => {
const caller = teamCaller(2);
// Accepted hackers can find teammates before kickoff.
it("allows team creation before hacking starts", async () => {
const caller = teamCaller(-48);
mockInsert.mockReturnValue([{ id: "team_1", name: "meow" }]);

await expect(caller.team.createTeam(newTeam)).rejects.toThrow(
/not open yet/,
);
await expect(caller.team.createTeam(newTeam)).resolves.toMatchObject({
name: "meow",
});
});

it("allows team creation inside the window", async () => {
Expand Down Expand Up @@ -649,18 +671,21 @@ describe("Hackathon end-to-end flow", () => {
expect(res.canLeave).toBe(false);
});

// Teams form from acceptance, not from +12h: two hours in, an accepted
// hacker can already create, join and leave.
it("reports the window so the UI can disable instead of failing", async () => {
const caller = teamCaller(2);

const res = await caller.team.window({ hackathonId: HACK_A });
expect(res).toMatchObject({
isOpen: false,
canCreate: false,
canJoin: false,
canLeave: false,
isOpen: true,
canCreate: true,
canJoin: true,
canLeave: true,
opensAt: null,
});
expect(res.leaveLocksAt.getTime() - res.opensAt.getTime()).toBe(
12 * HOUR,
expect(res.closesAt.getTime() - res.leaveLocksAt.getTime()).toBe(
10 * HOUR,
);
});
});
Expand Down
17 changes: 9 additions & 8 deletions packages/api/src/.internal-tests/judge-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1264,22 +1264,23 @@ describe("Judge edge cases", () => {
});

/**
* Activating and deactivating a person is the super-admin tier. A plain
* admin runs the event; deciding who holds a role does not come with that.
* Approving a judge is part of running the event, so any admin may; it
* was super-admin only while every admin saw the button. Deleting the
* judge, votes and all, stays super-admin.
*/
it("refuses a plain admin, and writes nothing", async () => {
it("lets a plain admin approve a judge but not remove one", async () => {
mockFindFirst.mockImplementation((table: string) =>
table === "admins" ? PLAIN_ADMIN_ROW : undefined,
);

await expect(
adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: true }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const approval = await adminCaller()
.judge.setActive({ judgeId: JUDGE_ID, isActive: true })
.catch((error: unknown) => error);
expect((approval as { code?: string })?.code).not.toBe("FORBIDDEN");

await expect(
adminCaller().judge.remove({ judgeId: JUDGE_ID }),
).rejects.toMatchObject({ code: "FORBIDDEN" });

expect(mockUpdate).not.toHaveBeenCalled();
expect(mockDelete).not.toHaveBeenCalled();
});

Expand Down
Loading
Loading