Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
47 changes: 33 additions & 14 deletions apps/api/src/billing/http-schemas/usage.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import { GetUsageHistoryQuerySchema } from "./usage.schema";

describe("Usage Schema", () => {
describe("GetUsageHistoryQuerySchema", () => {
const address = "akash18andxgtd6r08zzfpcdqg9pdr6smks7gv76tyt6";

it("derives startDate as 30 days before the provided endDate", () => {
it("defaults startDate so the window covers the 30 days ending at endDate", () => {
const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-01-31" });

expect(result.startDate).toBe("2024-01-01");
expect(result.startDate).toBe("2024-01-02");
expect(result.endDate).toBe("2024-01-31");
});

it("derives startDate across month and year boundaries", () => {
const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-01-15" });

expect(result.startDate).toBe("2023-12-16");
expect(result.startDate).toBe("2023-12-17");
expect(result.endDate).toBe("2024-01-15");
});

it("computes startDate in UTC regardless of the process timezone", () => {
const originalTimezone = process.env.TZ;
process.env.TZ = "America/New_York";
vi.stubEnv("TZ", "America/New_York");

try {
const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-11-15" });

expect(result.startDate).toBe("2024-10-16");
expect(result.startDate).toBe("2024-10-17");
expect(result.endDate).toBe("2024-11-15");
} finally {
process.env.TZ = originalTimezone;
vi.unstubAllEnvs();
}
});

Expand All @@ -41,23 +40,43 @@ describe("Usage Schema", () => {
expect(result.endDate).toBe("2024-01-31");
});

it("defaults endDate to today and spans a 30-day window when both dates are omitted", () => {
it("defaults endDate to today and covers 30 days when both dates are omitted", () => {
const result = GetUsageHistoryQuerySchema.parse({ address });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

expect(result.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const spanInDays = (Date.parse(result.endDate) - Date.parse(result.startDate)) / (24 * 60 * 60 * 1000);
expect(spanInDays).toBe(30);
expect(result.endDate).toBe(new Date().toISOString().split("T")[0]);
const windowDays = (Date.parse(result.endDate) - Date.parse(result.startDate)) / (24 * 60 * 60 * 1000) + 1;
expect(windowDays).toBe(30);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

it("accepts a single-day range", () => {
const result = GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-01-31", endDate: "2024-01-31" });

expect(result.startDate).toBe("2024-01-31");
expect(result.endDate).toBe("2024-01-31");
});

it("accepts a range of exactly 366 days", () => {
const result = GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-01-01", endDate: "2024-12-31" });

expect(result.startDate).toBe("2024-01-01");
expect(result.endDate).toBe("2024-12-31");
});

it("rejects a range wider than 366 days", () => {
expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2023-01-01", endDate: "2024-12-31" })).toThrow(
"Date range cannot exceed 366 days and startDate must be before endDate"
"Date range cannot exceed 366 days and startDate must not be after endDate"
);
});

it("rejects a range of 367 days", () => {
expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-01-01", endDate: "2025-01-01" })).toThrow(
"Date range cannot exceed 366 days and startDate must not be after endDate"
);
});

it("rejects a startDate after the endDate", () => {
expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-02-01", endDate: "2024-01-01" })).toThrow(
"Date range cannot exceed 366 days and startDate must be before endDate"
"Date range cannot exceed 366 days and startDate must not be after endDate"
);
});
});
Expand Down
53 changes: 33 additions & 20 deletions apps/api/src/billing/http-schemas/usage.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,57 @@ import { z } from "@hono/zod-openapi";

import { AkashAddressSchema } from "@src/utils/schema";

const DEFAULT_USAGE_WINDOW_DAYS = 30;
const MAX_USAGE_WINDOW_DAYS = 366;
const MS_PER_DAY = 24 * 60 * 60 * 1000;

function toIsoDate(date: Date) {
return date.toISOString().split("T")[0];
}

function startOfInclusiveWindow(endDate: string, windowDays: number) {
const date = new Date(`${endDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() - (windowDays - 1));
return toIsoDate(date);
}

function countInclusiveDays(startDate: string, endDate: string) {
return (Date.parse(endDate) - Date.parse(startDate)) / MS_PER_DAY + 1;
}

export const GetUsageHistoryQuerySchema = z
.object({
address: AkashAddressSchema.openapi({
description: "The wallet address to get billing and usage data for",
example: "akash18andxgtd6r08zzfpcdqg9pdr6smks7gv76tyt6"
}),
startDate: z.string().date().optional().openapi({
description: "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
example: "2024-01-01"
}),
startDate: z
.string()
.date()
.optional()
.openapi({
description: `Start date (YYYY-MM-DD), inclusive. Defaults to a ${DEFAULT_USAGE_WINDOW_DAYS}-day window ending at endDate`,
example: "2024-01-01"
}),
endDate: z.string().date().optional().openapi({
description: "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
description: "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
example: "2024-01-31"
})
})
.transform(data => {
const endDate = data.endDate ?? new Date().toISOString().split("T")[0];
const endDate = data.endDate ?? toIsoDate(new Date());
const startDate = data.startDate ?? startOfInclusiveWindow(endDate, DEFAULT_USAGE_WINDOW_DAYS);

if (data.startDate) {
return { ...data, startDate: data.startDate, endDate };
}

const startDate = new Date(`${endDate}T00:00:00.000Z`);
startDate.setUTCDate(startDate.getUTCDate() - 30);

return { ...data, startDate: startDate.toISOString().split("T")[0], endDate };
return { ...data, startDate, endDate };
})
.refine(
data => {
const end = new Date(data.endDate);
const start = new Date(data.startDate);

const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000));
const windowDays = countInclusiveDays(data.startDate, data.endDate);

return start <= end && daysDiff <= 366;
return windowDays >= 1 && windowDays <= MAX_USAGE_WINDOW_DAYS;
},
{
message: "Date range cannot exceed 366 days and startDate must be before endDate"
message: `Date range cannot exceed ${MAX_USAGE_WINDOW_DAYS} days and startDate must not be after endDate`
}
);

Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/billing/routes/usage/usage.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const getUsageHistoryRoute = createRoute({
}
},
400: {
description: "Invalid address format"
description:
"Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
});
Expand All @@ -50,7 +51,8 @@ const getUsageHistoryStatsRoute = createRoute({
}
},
400: {
description: "Invalid address format"
description:
"Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
});
Expand Down
12 changes: 6 additions & 6 deletions apps/api/swagger/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1777,7 +1777,7 @@
"schema": {
"type": "string",
"format": "date",
"description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01"
},
"required": false,
Expand All @@ -1788,7 +1788,7 @@
"schema": {
"type": "string",
"format": "date",
"description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31"
},
"required": false,
Expand Down Expand Up @@ -1875,7 +1875,7 @@
}
},
"400": {
"description": "Invalid address format"
"description": "Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
}
Expand All @@ -1902,7 +1902,7 @@
"schema": {
"type": "string",
"format": "date",
"description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01"
},
"required": false,
Expand All @@ -1913,7 +1913,7 @@
"schema": {
"type": "string",
"format": "date",
"description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31"
},
"required": false,
Expand Down Expand Up @@ -1961,7 +1961,7 @@
}
},
"400": {
"description": "Invalid address format"
"description": "Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions apps/api/test/functional/__snapshots__/docs.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -14213,7 +14213,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"name": "startDate",
"required": false,
"schema": {
"description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01",
"format": "date",
"type": "string",
Expand All @@ -14224,7 +14224,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"name": "endDate",
"required": false,
"schema": {
"description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31",
"format": "date",
"type": "string",
Expand Down Expand Up @@ -14310,7 +14310,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"description": "Returns billing and usage data",
},
"400": {
"description": "Invalid address format",
"description": "Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days",
},
},
"security": [],
Expand Down Expand Up @@ -14338,7 +14338,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"name": "startDate",
"required": false,
"schema": {
"description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01",
"format": "date",
"type": "string",
Expand All @@ -14349,7 +14349,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"name": "endDate",
"required": false,
"schema": {
"description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31",
"format": "date",
"type": "string",
Expand Down Expand Up @@ -14396,7 +14396,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"description": "Returns usage stats data",
},
"400": {
"description": "Invalid address format",
"description": "Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days",
},
},
"security": [],
Expand Down
38 changes: 30 additions & 8 deletions apps/api/test/functional/usage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ describe("GET /v1/usage/history", () => {
it("returns usage history for a valid address with default date range", async () => {
const { owners } = await setup();
const response = await app.request(`/v1/usage/history?address=${owners[0]}`);
await expectUsageHistory(response, 31);
await expectUsageHistory(response, 30);
});

it("returns usage history for a valid address with custom date range", async () => {
Expand All @@ -318,7 +318,7 @@ describe("GET /v1/usage/history", () => {
it("returns empty array for address with no leases", async () => {
const { owners } = await setup();
const response = await app.request(`/v1/usage/history?address=${owners[2]}`);
const data = await expectUsageHistory(response, 31);
const data = await expectUsageHistory(response, 30);

data.forEach(day => {
expect(day.activeDeployments).toBe(0);
Expand Down Expand Up @@ -382,9 +382,21 @@ describe("GET /v1/usage/history", () => {
expect(response.status).toBe(400);
});

it("responds with 400 when date range exceeds 365 days", async () => {
it("returns a full window for a range of exactly 366 days", async () => {
const { owners, now } = await setup();
const startDate = formatUTCDate(subDays(now, 400));
const startDate = formatUTCDate(subDays(now, 365));
const endDate = formatUTCDate(now);

const response = await app.request(`/v1/usage/history?address=${owners[0]}&startDate=${startDate}&endDate=${endDate}`);
const data = await expectUsageHistory(response, 366);

expect(data[0].date).toBe(startDate);
expect(data[data.length - 1].date).toBe(endDate);
});

it("responds with 400 when the date range spans 367 days", async () => {
const { owners, now } = await setup();
const startDate = formatUTCDate(subDays(now, 366));
const endDate = formatUTCDate(now);

const response = await app.request(`/v1/usage/history?address=${owners[0]}&startDate=${startDate}&endDate=${endDate}`);
Expand All @@ -396,15 +408,15 @@ describe("GET /v1/usage/history", () => {
const endDate = formatUTCDate(now);

const response = await app.request(`/v1/usage/history?address=${owners[0]}&endDate=${endDate}`);
await expectUsageHistory(response, 31); // 30 days before endDate + endDate itself
await expectUsageHistory(response, 30);
});

it("uses current date as default endDate when not provided", async () => {
const { now, owners } = await setup();
const startDate = formatUTCDate(subDays(now, 5));

const response = await app.request(`/v1/usage/history?address=${owners[0]}&startDate=${startDate}`);
await expectUsageHistory(response, 6); // 5 days + today
await expectUsageHistory(response, 6);
});
});

Expand Down Expand Up @@ -484,10 +496,20 @@ describe("GET /v1/usage/history/stats", () => {
expect(response.status).toBe(400);
});

it("responds with 400 when date range exceeds 365 days", async () => {
it("returns stats for a range of exactly 366 days", async () => {
const { owners, expectUsageStats } = setup();
const now = new Date();
const startDate = formatUTCDate(subDays(now, 365));
const endDate = formatUTCDate(now);

const response = await app.request(`/v1/usage/history/stats?address=${owners[0]}&startDate=${startDate}&endDate=${endDate}`);
await expectUsageStats(response);
});

it("responds with 400 when the date range spans 367 days", async () => {
const { owners } = setup();
const now = new Date();
const startDate = formatUTCDate(subDays(now, 400));
const startDate = formatUTCDate(subDays(now, 366));
const endDate = formatUTCDate(now);

const response = await app.request(`/v1/usage/history/stats?address=${owners[0]}&startDate=${startDate}&endDate=${endDate}`);
Expand Down
Loading