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
73 changes: 72 additions & 1 deletion packages/validation/src/schemas/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from "bun:test";
import { analyticsEventSchema } from "./analytics";
import {
analyticsDateRangeSchema,
analyticsEventSchema,
} from "./analytics";

const validEvent = {
eventId: "test-id",
Expand Down Expand Up @@ -96,3 +99,71 @@ describe("analyticsEventSchema referrer validation", () => {
expect(result.success).toBe(false);
});
});

describe("analyticsDateRangeSchema", () => {
it("accepts a complete range", () => {
const result = analyticsDateRangeSchema.safeParse({
startDate: "2026-01-01",
endDate: "2026-01-31",
});
expect(result.success).toBe(true);
});

it("accepts no dates at all", () => {
const result = analyticsDateRangeSchema.safeParse({});
expect(result.success).toBe(true);
});

it("rejects a start date without an end date", () => {
const result = analyticsDateRangeSchema.safeParse({
startDate: "2026-01-01",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
"endDate is required when startDate is provided"
);
}
});

it("rejects an end date without a start date", () => {
const result = analyticsDateRangeSchema.safeParse({
endDate: "2026-01-31",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
"startDate is required when endDate is provided"
);
}
});

it("rejects an inverted range", () => {
const result = analyticsDateRangeSchema.safeParse({
startDate: "2026-02-01",
endDate: "2026-01-31",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
"startDate must be on or before endDate"
);
}
});

it("accepts equal start and end dates", () => {
const result = analyticsDateRangeSchema.safeParse({
startDate: "2026-01-31",
endDate: "2026-01-31",
});
expect(result.success).toBe(true);
});

it("rejects non-ISO date strings", () => {
const result = analyticsDateRangeSchema.safeParse({
startDate: "01/2026",
endDate: "2026-01-31",
});
expect(result.success).toBe(false);
});
});
29 changes: 29 additions & 0 deletions packages/validation/src/schemas/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,35 @@ const timestampSchema = z
}
);

const analyticsDateOnlySchema = z.iso.date();

export const analyticsDateRangeSchema = z
.object({
startDate: analyticsDateOnlySchema.optional(),
endDate: analyticsDateOnlySchema.optional(),
Comment on lines +72 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial ranges are silently discarded

When an MCP analytics request supplies only from or only to, this schema accepts the incomplete range, but the downstream procedure requires both endpoints and replaces it with its default seven-day range, returning analytics for dates the caller did not request.

})
.superRefine((range, ctx) => {
const hasStart = Boolean(range.startDate);
const hasEnd = Boolean(range.endDate);
if (hasStart !== hasEnd) {
ctx.addIssue({
code: "custom",
message: hasStart
? "endDate is required when startDate is provided"
: "startDate is required when endDate is provided",
path: [hasStart ? "endDate" : "startDate"],
});
return;
}
if (range.startDate && range.endDate && range.startDate > range.endDate) {
ctx.addIssue({
code: "custom",
message: "startDate must be on or before endDate",
path: ["startDate"],
});
}
});

export const analyticsEventSchema = z.object({
eventId: z.string().max(VALIDATION_LIMITS.EVENT_ID_MAX_LENGTH),
name: z.string().min(1).max(VALIDATION_LIMITS.NAME_MAX_LENGTH),
Expand Down