Skip to content
Open
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
92 changes: 92 additions & 0 deletions packages/das/src/api/miners/miners.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { BadRequestException } from "@nestjs/common";
import { MinersController } from "./miners.controller";
import { MinersService } from "./miners.service";

// The GET endpoints hand `since` straight to SQL as a `::timestamptz`
// parameter. The controller must reject values Postgres would choke on —
// turning a client mistake into a 400 instead of a QueryFailedError 500 —
// and normalize accepted values to ISO, exactly like the POST endpoints'
// parseSinceByRepo and the dashboard controller already do.
function makeController(): {
controller: MinersController;
getPullRequests: jest.Mock;
getIssues: jest.Mock;
} {
const getPullRequests = jest.fn().mockResolvedValue({});
const getIssues = jest.fn().mockResolvedValue({});
const service = { getPullRequests, getIssues } as unknown as MinersService;
return {
controller: new MinersController(service),
getPullRequests,
getIssues,
};
}

describe("GET /miners/:githubId/pulls `since` validation", () => {
it("rejects a non-timestamp `since` with 400 before reaching the service", async () => {
const { controller, getPullRequests } = makeController();

await expect(
controller.getPullRequests("1", "banana"),
).rejects.toBeInstanceOf(BadRequestException);
expect(getPullRequests).not.toHaveBeenCalled();
});

it("normalizes a valid `since` to a full ISO timestamp", async () => {
const { controller, getPullRequests } = makeController();

await controller.getPullRequests("1", "2026-06-01");

expect(getPullRequests).toHaveBeenCalledTimes(1);
expect(getPullRequests.mock.calls[0][1]).toBe("2026-06-01T00:00:00.000Z");
});

it("falls back to the default window when `since` is omitted", async () => {
const { controller, getPullRequests } = makeController();

await controller.getPullRequests("1");

expect(getPullRequests).toHaveBeenCalledTimes(1);
const since: unknown = getPullRequests.mock.calls[0][1];
expect(typeof since).toBe("string");
expect(Number.isNaN(Date.parse(since as string))).toBe(false);
});
});

describe("GET /miners/:githubId/issues `since` validation", () => {
it("rejects a non-timestamp `since` with 400 before reaching the service", async () => {
const { controller, getIssues } = makeController();

await expect(controller.getIssues("1", "banana")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(getIssues).not.toHaveBeenCalled();
});

it("passes null when `since` is omitted (open-issue load counting mode)", async () => {
const { controller, getIssues } = makeController();

await controller.getIssues("1");

expect(getIssues).toHaveBeenCalledTimes(1);
expect(getIssues.mock.calls[0][1]).toBeNull();
});

it("treats an empty `since` as omitted instead of casting '' to timestamptz", async () => {
const { controller, getIssues } = makeController();

await controller.getIssues("1", "");

expect(getIssues).toHaveBeenCalledTimes(1);
expect(getIssues.mock.calls[0][1]).toBeNull();
});

it("normalizes a valid `since` to a full ISO timestamp", async () => {
const { controller, getIssues } = makeController();

await controller.getIssues("1", "2026-06-01");

expect(getIssues).toHaveBeenCalledTimes(1);
expect(getIssues.mock.calls[0][1]).toBe("2026-06-01T00:00:00.000Z");
});
});
25 changes: 23 additions & 2 deletions packages/das/src/api/miners/miners.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ interface SinceByRepoBody {
since_by_repo?: Record<string, unknown>;
}

/**
* Validate an optional `since` query value. Absent or empty resolves to
* undefined so callers apply their own default; anything else must parse as
* a date and is normalized to ISO. The value is bound to a `::timestamptz`
* cast in SQL, so rejecting garbage here turns a client mistake into a 400
* instead of a QueryFailedError 500 — matching the validation the POST
* endpoints (parseSinceByRepo) and the dashboard controller already apply.
*/
function parseSinceQuery(since?: string): string | undefined {
if (since === undefined || since === "") return undefined;
const parsed = new Date(since);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException("`since` must be a valid ISO timestamp");
}
return parsed.toISOString();
}

/**
* Validate a `{ since_by_repo: { "<owner/repo>": "<ISO timestamp>" } }` body
* into parallel `repoNames` / `sinceValues` arrays. Repo names are lowercased
Expand Down Expand Up @@ -143,7 +160,7 @@ export class MinersController {
const pagination = parsePaginationQuery(limit, cursor);
return this.miners.getPullRequests(
githubId,
MinersService.resolveSince(since),
MinersService.resolveSince(parseSinceQuery(since)),
pagination,
);
}
Expand Down Expand Up @@ -222,7 +239,11 @@ export class MinersController {
@Query("limit") limit?: string,
): Promise<unknown> {
const pagination = parsePaginationQuery(limit, cursor);
return this.miners.getIssues(githubId, since ?? null, pagination);
return this.miners.getIssues(
githubId,
parseSinceQuery(since) ?? null,
pagination,
);
}

@Post(":githubId/issues")
Expand Down