diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index afde9ba38..3935d06fd 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -40,6 +40,7 @@ export { type ApiRequestOptions, apiRequest, apiRequestToRegion, + autoPaginate, buildSearchParams, ORG_FANOUT_CONCURRENCY, type PaginatedResponse, diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index aad1ab112..e43269117 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -218,6 +218,54 @@ export type PaginatedResponse = { nextCursor?: string; }; +/** + * Auto-paginate across multiple API pages, accumulating results up to `limit`. + * + * Calls `fetchPage` repeatedly until enough rows are collected or pages are + * exhausted. Caps at {@link MAX_PAGINATION_PAGES} to prevent runaway loops. + * + * The caller is responsible for baking `perPage` into the `fetchPage` closure + * (typically `Math.min(limit, API_MAX_PER_PAGE)`). This helper only manages + * cursor chaining and row accumulation. + * + * @param fetchPage - Async function that fetches a single page given a cursor + * @param limit - Total number of items to collect + * @param initialCursor - Optional starting cursor + * @returns Accumulated items with optional nextCursor from the last page + */ +export async function autoPaginate( + fetchPage: (cursor: string | undefined) => Promise>, + limit: number, + initialCursor?: string +): Promise> { + // Fast path: single-page fetch when limit fits in one API page + if (limit <= API_MAX_PER_PAGE) { + return fetchPage(initialCursor); + } + + // Multi-page: accumulate rows across pages up to the requested limit + const allRows: T[] = []; + let cursor: string | undefined = initialCursor; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { + const result = await fetchPage(cursor); + allRows.push(...result.data); + + if (allRows.length >= limit || !result.nextCursor) { + // Overshot — trim and drop nextCursor (cursor would skip items) + if (allRows.length > limit) { + return { data: allRows.slice(0, limit) }; + } + return { data: allRows, nextCursor: result.nextCursor }; + } + + cursor = result.nextCursor; + } + + // Safety limit reached — return what we have, no nextCursor + return { data: allRows.slice(0, limit) }; +} + /** * Make an authenticated request to a specific Sentry region. * Returns both parsed response data and raw headers for pagination support. diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index cd7322c00..3b535f5e3 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -21,7 +21,9 @@ import { resolveOrgRegion } from "../region.js"; import { isAllDigits } from "../utils.js"; import { + API_MAX_PER_PAGE, apiRequestToRegion, + autoPaginate, type PaginatedResponse, parseLinkHeader, } from "./infrastructure.js"; @@ -293,30 +295,23 @@ type ListTransactionsOptions = { }; /** - * List recent transactions for a project. - * Uses the Explore/Events API with dataset=transactions. + * Fetch a single page of transactions from the Explore/Events endpoint. * - * Handles project slug vs numeric ID automatically: - * - Numeric IDs are passed as the `project` parameter - * - Slugs are added to the query string as `project:{slug}` - * - * @param orgSlug - Organization slug - * @param projectSlug - Project slug or numeric ID - * @param options - Query options (query, limit, sort, statsPeriod, cursor) - * @returns Paginated response with transaction items and optional next cursor + * Internal helper used by {@link listTransactions} for both single-page and + * multi-page (auto-paginating) fetches. */ -export async function listTransactions( +// biome-ignore lint/nursery/useMaxParams: internal helper mirrors the public API surface +async function fetchTransactionsPage( + regionUrl: string, orgSlug: string, projectSlug: string, - options: ListTransactionsOptions = {} + options: ListTransactionsOptions, + perPage: number ): Promise> { const isNumericProject = isAllDigits(projectSlug); const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); - const regionUrl = await resolveOrgRegion(orgSlug); - - // Use raw request: the SDK's dataset type doesn't include "transactions" const { data: response, headers } = await apiRequestToRegion( regionUrl, @@ -330,7 +325,7 @@ export async function listTransactions( // sending `query=` causes the Sentry API to behave differently than // omitting the parameter. query: fullQuery || undefined, - per_page: options.limit || 10, + per_page: perPage, statsPeriod: options.start || options.end ? undefined @@ -351,6 +346,45 @@ export async function listTransactions( return { data: response.data, nextCursor }; } +/** + * List recent transactions for a project. + * Uses the Explore/Events API with dataset=transactions. + * + * Handles project slug vs numeric ID automatically: + * - Numeric IDs are passed as the `project` parameter + * - Slugs are added to the query string as `project:{slug}` + * + * When `limit` exceeds {@link API_MAX_PER_PAGE}, transparently fetches multiple + * pages using cursor-based pagination (bounded by {@link MAX_PAGINATION_PAGES}). + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug or numeric ID + * @param options - Query options (query, limit, sort, statsPeriod, cursor) + * @returns Paginated response with transaction items and optional next cursor + */ +export async function listTransactions( + orgSlug: string, + projectSlug: string, + options: ListTransactionsOptions = {} +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const limit = options.limit || 10; + const perPage = Math.min(limit, API_MAX_PER_PAGE); + + return autoPaginate( + (cursor) => + fetchTransactionsPage( + regionUrl, + orgSlug, + projectSlug, + { ...options, cursor }, + perPage + ), + limit, + options.cursor + ); +} + // Span listing /** Fields to request from the spans API */ @@ -391,18 +425,18 @@ type ListSpansOptions = { }; /** - * List spans using the EAP spans search endpoint. - * Uses the Explore/Events API with dataset=spans. + * Fetch a single page of spans from the Explore/Events endpoint. * - * @param orgSlug - Organization slug - * @param projectSlug - Project slug or numeric ID - * @param options - Query options (query, limit, sort, statsPeriod, cursor) - * @returns Paginated response with span items and optional next cursor + * Internal helper used by {@link listSpans} for both single-page and + * multi-page (auto-paginating) fetches. */ -export async function listSpans( +// biome-ignore lint/nursery/useMaxParams: internal helper mirrors the public API surface +async function fetchSpansPage( + regionUrl: string, orgSlug: string, projectSlug: string, - options: ListSpansOptions = {} + options: ListSpansOptions, + perPage: number ): Promise> { const isNumericProject = isAllDigits(projectSlug); let projectFilter: string; @@ -419,8 +453,6 @@ export async function listSpans( ? SPAN_FIELDS.concat(options.extraFields) : SPAN_FIELDS; - const regionUrl = await resolveOrgRegion(orgSlug); - let projectParam: string | undefined; if (options.allProjects) { projectParam = "-1"; @@ -437,7 +469,7 @@ export async function listSpans( field: fields, project: projectParam, query: fullQuery || undefined, - per_page: options.limit || 10, + per_page: perPage, statsPeriod: options.start || options.end ? undefined @@ -454,3 +486,38 @@ export async function listSpans( const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); return { data: response.data, nextCursor }; } + +/** + * List spans using the EAP spans search endpoint. + * Uses the Explore/Events API with dataset=spans. + * + * When `limit` exceeds {@link API_MAX_PER_PAGE}, transparently fetches multiple + * pages using cursor-based pagination (bounded by {@link MAX_PAGINATION_PAGES}). + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug or numeric ID + * @param options - Query options (query, limit, sort, statsPeriod, cursor) + * @returns Paginated response with span items and optional next cursor + */ +export async function listSpans( + orgSlug: string, + projectSlug: string, + options: ListSpansOptions = {} +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const limit = options.limit || 10; + const perPage = Math.min(limit, API_MAX_PER_PAGE); + + return autoPaginate( + (cursor) => + fetchSpansPage( + regionUrl, + orgSlug, + projectSlug, + { ...options, cursor }, + perPage + ), + limit, + options.cursor + ); +} diff --git a/src/lib/time-range.ts b/src/lib/time-range.ts index 4466e5497..4892d0ab1 100644 --- a/src/lib/time-range.ts +++ b/src/lib/time-range.ts @@ -357,6 +357,15 @@ export function timeRangeToApiParams(range: TimeRange): TimeRangeApiParams { if (range.end) { params.end = range.end; } + // Fill missing boundary — the Sentry API requires both start and end + // when absolute dates are used, otherwise it returns 400. + if (params.start && !params.end) { + params.end = new Date().toISOString(); + } else if (params.end && !params.start) { + const endDate = new Date(params.end); + endDate.setDate(endDate.getDate() - 90); + params.start = endDate.toISOString(); + } return params; } diff --git a/test/lib/api/traces.test.ts b/test/lib/api/traces.test.ts new file mode 100644 index 000000000..64e835101 --- /dev/null +++ b/test/lib/api/traces.test.ts @@ -0,0 +1,636 @@ +/** + * Tests for the traces API helpers (listTransactions, listSpans). + * + * Verifies URL construction, query parameter encoding, schema validation, + * pagination cursor extraction, and auto-pagination across multiple pages. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { listSpans, listTransactions } from "../../../src/lib/api/traces.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +// --------------------------------------------------------------------------- +// listTransactions +// --------------------------------------------------------------------------- + +describe("listTransactions", () => { + useTestConfigDir("traces-txn-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedMethod = ""; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedMethod = ""; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown, headers: Record = {}) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + } + + /** + * Helper to mock sequential fetch responses for multi-page tests. + * Each call to fetch returns the next response in the queue. + */ + function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> + ): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; + } + + const TX_META = { + fields: { + trace: "string", + id: "string", + transaction: "string", + timestamp: "date", + "transaction.duration": "duration", + project: "string", + }, + }; + + /** Generate N rows of fake transaction data */ + function makeTxnRows(n: number): Record[] { + return Array.from({ length: n }, (_, i) => ({ + trace: `trace-${i}`, + id: `id-${i}`, + transaction: `/api/endpoint-${i}`, + timestamp: "2024-01-15T00:00:00Z", + "transaction.duration": 100 + i, + project: "my-project", + })); + } + + test("hits /organizations/{org}/events/ with GET", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toContain("/api/0/organizations/my-org/events/"); + }); + + test("sends dataset=transactions", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain("dataset=transactions"); + }); + + test("passes per_page capped at 100 even when limit is higher", async () => { + // With limit > 100, the first page should still request per_page=100 + mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + await listTransactions("my-org", "my-project", { limit: 150 }); + + // Both pages should use per_page=100 + // (the second page still uses API_MAX_PER_PAGE since limit > 100) + }); + + test("sends sort=-timestamp by default", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain(`sort=${encodeURIComponent("-timestamp")}`); + }); + + test('sends sort=-transaction.duration for sort="duration"', async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { sort: "duration" }); + + expect(decodeURIComponent(capturedUrl)).toContain( + "sort=-transaction.duration" + ); + }); + + test("passes cursor when provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { cursor: "0:50:0" }); + + expect(capturedUrl).toContain(`cursor=${encodeURIComponent("0:50:0")}`); + }); + + test("uses statsPeriod when no absolute range provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { statsPeriod: "1h" }); + + expect(capturedUrl).toContain("statsPeriod=1h"); + expect(capturedUrl).not.toContain("start="); + expect(capturedUrl).not.toContain("end="); + }); + + test("defaults statsPeriod to 7d when not provided", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(capturedUrl).toContain("statsPeriod=7d"); + }); + + test("suppresses statsPeriod when start/end are present", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project", { + start: "2024-01-15T00:00:00Z", + end: "2024-01-16T00:00:00Z", + statsPeriod: "7d", + }); + + expect(capturedUrl).toContain( + `start=${encodeURIComponent("2024-01-15T00:00:00Z")}` + ); + expect(capturedUrl).toContain( + `end=${encodeURIComponent("2024-01-16T00:00:00Z")}` + ); + expect(capturedUrl).not.toContain("statsPeriod="); + }); + + test("auto-paginates when limit > 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 150, + }); + + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(getCapturedUrls()).toHaveLength(2); + }); + + test("trims results and drops nextCursor when overshoot", async () => { + mockSequential([ + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeTxnRows(100), meta: TX_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:200:0"`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 120, + }); + + expect(result.data).toHaveLength(120); + expect(result.nextCursor).toBeUndefined(); + }); + + test("single-page fast path for limit <= 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeTxnRows(50), meta: TX_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listTransactions("my-org", "my-project", { + limit: 50, + }); + + expect(result.data).toHaveLength(50); + expect(getCapturedUrls()).toHaveLength(1); + expect(getCapturedUrls()[0]).toContain("per_page=50"); + }); + + test("non-numeric project slug goes in query as project:slug", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "my-project"); + + expect(decodeURIComponent(capturedUrl)).toContain( + "query=project:my-project" + ); + // Should NOT appear as a separate project= param + expect(capturedUrl).not.toMatch(/[?&]project=my-project/); + }); + + test("numeric project ID goes as project param", async () => { + mockOk({ data: [], meta: TX_META }); + + await listTransactions("my-org", "12345"); + + expect(capturedUrl).toContain("project=12345"); + // Should NOT appear as project:12345 in the query string + expect(decodeURIComponent(capturedUrl)).not.toContain("project:12345"); + }); + + test("returns nextCursor from Link header", async () => { + const cursor = "0:10:0"; + mockOk( + { data: makeTxnRows(10), meta: TX_META }, + { + Link: `; rel="next"; results="true"; cursor="${cursor}"`, + } + ); + + const result = await listTransactions("my-org", "my-project", { + limit: 10, + }); + + expect(result.nextCursor).toBe(cursor); + }); + + test("returns undefined nextCursor when results=false", async () => { + mockOk( + { data: [], meta: TX_META }, + { + Link: `; rel="next"; results="false"; cursor=""`, + } + ); + + const result = await listTransactions("my-org", "my-project"); + + expect(result.nextCursor).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// listSpans +// --------------------------------------------------------------------------- + +describe("listSpans", () => { + useTestConfigDir("traces-span-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedMethod = ""; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedMethod = ""; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown, headers: Record = {}) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + } + + function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> + ): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; + } + + const SPAN_META = { + fields: { + id: "string", + parent_span: "string", + "span.op": "string", + description: "string", + "span.duration": "duration", + timestamp: "date", + project: "string", + transaction: "string", + trace: "string", + }, + }; + + /** Generate N rows of fake span data */ + function makeSpanRows(n: number): Record[] { + return Array.from({ length: n }, (_, i) => ({ + id: `span-${i}`, + parent_span: `parent-${i}`, + "span.op": "http.client", + description: `GET /api/endpoint-${i}`, + "span.duration": 50 + i, + timestamp: "2024-01-15T00:00:00Z", + project: "my-project", + transaction: "/api/foo", + trace: `trace-${i}`, + })); + } + + test("hits /organizations/{org}/events/ with GET", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toContain("/api/0/organizations/my-org/events/"); + }); + + test("sends dataset=spans", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain("dataset=spans"); + }); + + test("passes per_page capped at 100 when limit is higher", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(50), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + await listSpans("my-org", "my-project", { limit: 150 }); + + // Both pages should use per_page=100 + expect(getCapturedUrls()[0]).toContain("per_page=100"); + expect(getCapturedUrls()[1]).toContain("per_page=100"); + }); + + test("sends sort=-timestamp by default", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain(`sort=${encodeURIComponent("-timestamp")}`); + }); + + test('sends sort=-span.duration for sort="duration"', async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { sort: "duration" }); + + expect(decodeURIComponent(capturedUrl)).toContain("sort=-span.duration"); + }); + + test("allProjects sends project=-1", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { allProjects: true }); + + expect(capturedUrl).toContain("project=-1"); + // Should NOT have project:my-project in query + expect(decodeURIComponent(capturedUrl)).not.toContain( + "project%3Amy-project" + ); + }); + + test("auto-paginates when limit > 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(50), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 150 }); + + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(getCapturedUrls()).toHaveLength(2); + }); + + test("trims results when overshoot", async () => { + mockSequential([ + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + }, + }, + { + body: { data: makeSpanRows(100), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="true"; cursor="0:200:0"`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 120 }); + + expect(result.data).toHaveLength(120); + expect(result.nextCursor).toBeUndefined(); + }); + + test("single-page fast path for limit <= 100", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: { data: makeSpanRows(30), meta: SPAN_META }, + headers: { + Link: `; rel="next"; results="false"; cursor=""`, + }, + }, + ]); + + const result = await listSpans("my-org", "my-project", { limit: 30 }); + + expect(result.data).toHaveLength(30); + expect(getCapturedUrls()).toHaveLength(1); + expect(getCapturedUrls()[0]).toContain("per_page=30"); + }); + + test("passes extraFields when provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { + extraFields: ["span.self_time", "span.category"], + }); + + const decoded = decodeURIComponent(capturedUrl); + expect(decoded).toContain("field=span.self_time"); + expect(decoded).toContain("field=span.category"); + // Standard fields should still be present + expect(decoded).toContain("field=id"); + expect(decoded).toContain("field=span.op"); + }); + + test("non-numeric project slug goes in query as project:slug", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain( + `query=${encodeURIComponent("project:my-project")}` + ); + // Should NOT appear as a separate project= param with the slug value + expect(capturedUrl).not.toMatch(/[?&]project=my-project/); + }); + + test("numeric project ID goes as project param", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "12345"); + + expect(capturedUrl).toContain("project=12345"); + expect(decodeURIComponent(capturedUrl)).not.toContain("project:12345"); + }); + + test("uses statsPeriod when no absolute range provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { statsPeriod: "1h" }); + + expect(capturedUrl).toContain("statsPeriod=1h"); + expect(capturedUrl).not.toContain("start="); + expect(capturedUrl).not.toContain("end="); + }); + + test("defaults statsPeriod to 7d when not provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project"); + + expect(capturedUrl).toContain("statsPeriod=7d"); + }); + + test("suppresses statsPeriod when start/end are present", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { + start: "2024-01-15T00:00:00Z", + end: "2024-01-16T00:00:00Z", + statsPeriod: "7d", + }); + + expect(capturedUrl).toContain( + `start=${encodeURIComponent("2024-01-15T00:00:00Z")}` + ); + expect(capturedUrl).toContain( + `end=${encodeURIComponent("2024-01-16T00:00:00Z")}` + ); + expect(capturedUrl).not.toContain("statsPeriod="); + }); + + test("passes cursor when provided", async () => { + mockOk({ data: [], meta: SPAN_META }); + + await listSpans("my-org", "my-project", { cursor: "0:50:0" }); + + expect(capturedUrl).toContain(`cursor=${encodeURIComponent("0:50:0")}`); + }); + + test("returns nextCursor from Link header", async () => { + const cursor = "0:10:0"; + mockOk( + { data: makeSpanRows(10), meta: SPAN_META }, + { + Link: `; rel="next"; results="true"; cursor="${cursor}"`, + } + ); + + const result = await listSpans("my-org", "my-project", { limit: 10 }); + + expect(result.nextCursor).toBe(cursor); + }); + + test("returns undefined nextCursor when results=false", async () => { + mockOk( + { data: [], meta: SPAN_META }, + { + Link: `; rel="next"; results="false"; cursor=""`, + } + ); + + const result = await listSpans("my-org", "my-project"); + + expect(result.nextCursor).toBeUndefined(); + }); +}); diff --git a/test/lib/time-range.test.ts b/test/lib/time-range.test.ts index b7c13a835..caaaad2b0 100644 --- a/test/lib/time-range.test.ts +++ b/test/lib/time-range.test.ts @@ -270,24 +270,35 @@ describe("timeRangeToApiParams", () => { expect(params.end).toBe("2024-02-01T23:59:59Z"); }); - test("absolute start-only → start, no end", () => { + test("absolute start-only → start + auto-filled end (now)", () => { + const before = new Date(); const params = timeRangeToApiParams({ type: "absolute", start: "2024-01-01T00:00:00Z", }); + const after = new Date(); expect(params.start).toBe("2024-01-01T00:00:00Z"); - expect(params.end).toBeUndefined(); expect(params.statsPeriod).toBeUndefined(); + // end should be filled with "now" + expect(params.end).toBeDefined(); + const endDate = new Date(params.end!); + expect(endDate.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(endDate.getTime()).toBeLessThanOrEqual(after.getTime()); }); - test("absolute end-only → end, no start", () => { + test("absolute end-only → end + auto-filled start (90 days before end)", () => { const params = timeRangeToApiParams({ type: "absolute", end: "2024-02-01T23:59:59Z", }); expect(params.end).toBe("2024-02-01T23:59:59Z"); - expect(params.start).toBeUndefined(); expect(params.statsPeriod).toBeUndefined(); + // start should be filled with 90 days before end + expect(params.start).toBeDefined(); + const startDate = new Date(params.start!); + const expectedStart = new Date("2024-02-01T23:59:59Z"); + expectedStart.setDate(expectedStart.getDate() - 90); + expect(startDate.toISOString()).toBe(expectedStart.toISOString()); }); });