diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 12aeb8c3..a2ba5469 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -136,13 +136,14 @@ sentry org view my-org -w Work with Sentry projects -#### `sentry project list ` +#### `sentry project list ` List projects **Flags:** - `-n, --limit - Maximum number of projects to list - (default: "30")` - `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` **Examples:** @@ -600,13 +601,14 @@ List organizations List projects -#### `sentry projects ` +#### `sentry projects ` List projects **Flags:** - `-n, --limit - Maximum number of projects to list - (default: "30")` - `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` ### Repos diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 01aa11f3..2f96b2db 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -1,14 +1,36 @@ /** * sentry project list * - * List projects in an organization. + * List projects in an organization with pagination and flexible targeting. + * + * Supports: + * - Auto-detection from DSN/config + * - Explicit org/project targeting (e.g., sentry/sentry) + * - Org-scoped listing with cursor pagination (e.g., sentry/) + * - Cross-org project search (e.g., sentry) */ import type { SentryContext } from "../../context.js"; -import { listOrganizations, listProjects } from "../../lib/api-client.js"; +import { + findProjectsBySlug, + getProject, + listOrganizations, + listProjects, + listProjectsPaginated, + type PaginatedResponse, +} from "../../lib/api-client.js"; +import { + type ParsedOrgProject, + parseOrgProjectArg, +} from "../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { getDefaultOrganization } from "../../lib/db/defaults.js"; -import { AuthError } from "../../lib/errors.js"; +import { + clearPaginationCursor, + getPaginationCursor, + setPaginationCursor, +} from "../../lib/db/pagination.js"; +import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; import { calculateProjectColumnWidths, formatProjectRow, @@ -16,24 +38,36 @@ import { writeJson, } from "../../lib/formatters/index.js"; import { resolveAllTargets } from "../../lib/resolve-target.js"; +import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryProject, Writer } from "../../types/index.js"; +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "project-list"; + type ListFlags = { readonly limit: number; readonly json: boolean; + readonly cursor?: string; readonly platform?: string; }; -/** Project with its organization context for display */ +/** + * Project with optional organization context for display. + * Uses optional orgSlug since some internal functions (e.g., filterByPlatform) + * operate on projects before org context is attached. + * The canonical exported type with required orgSlug lives in api-client.ts. + */ type ProjectWithOrg = SentryProject & { orgSlug?: string }; /** - * Fetch projects for a single organization. + * Fetch projects for a single organization (all pages). * * @param orgSlug - Organization slug to fetch projects from * @returns Projects with org context attached */ -async function fetchOrgProjects(orgSlug: string): Promise { +export async function fetchOrgProjects( + orgSlug: string +): Promise { const projects = await listProjects(orgSlug); return projects.map((p) => ({ ...p, orgSlug })); } @@ -42,7 +76,7 @@ async function fetchOrgProjects(orgSlug: string): Promise { * Fetch projects for a single org, returning empty array on non-auth errors. * Auth errors propagate so user sees "please log in" message. */ -async function fetchOrgProjectsSafe( +export async function fetchOrgProjectsSafe( orgSlug: string ): Promise { try { @@ -61,7 +95,7 @@ async function fetchOrgProjectsSafe( * * @returns Combined list of projects from all accessible orgs */ -async function fetchAllOrgProjects(): Promise { +export async function fetchAllOrgProjects(): Promise { const orgs = await listOrganizations(); const results: ProjectWithOrg[] = []; @@ -87,7 +121,7 @@ async function fetchAllOrgProjects(): Promise { * @param platform - Platform substring to match (e.g., "javascript", "python") * @returns Filtered projects, or all projects if no platform specified */ -function filterByPlatform( +export function filterByPlatform( projects: ProjectWithOrg[], platform?: string ): ProjectWithOrg[] { @@ -103,7 +137,7 @@ function filterByPlatform( /** * Write the column header row for project list output. */ -function writeHeader( +export function writeHeader( stdout: Writer, orgWidth: number, slugWidth: number, @@ -115,7 +149,7 @@ function writeHeader( stdout.write(`${org} ${project} ${name} PLATFORM\n`); } -type WriteRowsOptions = { +export type WriteRowsOptions = { stdout: Writer; projects: ProjectWithOrg[]; orgWidth: number; @@ -126,7 +160,7 @@ type WriteRowsOptions = { /** * Write formatted project rows to stdout. */ -function writeRows(options: WriteRowsOptions): void { +export function writeRows(options: WriteRowsOptions): void { const { stdout, projects, orgWidth, slugWidth, nameWidth } = options; for (const project of projects) { stdout.write( @@ -135,6 +169,71 @@ function writeRows(options: WriteRowsOptions): void { } } +/** + * Build a context key for pagination cursor validation. + * Captures the query parameters that affect result ordering, + * so cursors from different queries are not accidentally reused. + * + * Includes the Sentry host so cursors from different instances + * (SaaS vs self-hosted) are never mixed. + * + * Format: `host:|type:[:][|platform:]` + */ +export function buildContextKey( + parsed: ParsedOrgProject, + flags: { platform?: string }, + host: string +): string { + const parts: string[] = [`host:${host}`]; + switch (parsed.type) { + case "org-all": + parts.push(`type:org:${parsed.org}`); + break; + case "auto-detect": + parts.push("type:auto"); + break; + case "explicit": + parts.push(`type:explicit:${parsed.org}/${parsed.project}`); + break; + case "project-search": + parts.push(`type:search:${parsed.projectSlug}`); + break; + default: { + const _exhaustive: never = parsed; + parts.push(`type:unknown:${String(_exhaustive)}`); + } + } + if (flags.platform) { + // Normalize to lowercase since platform filtering is case-insensitive + parts.push(`platform:${flags.platform.toLowerCase()}`); + } + return parts.join("|"); +} + +/** + * Resolve the cursor value from --cursor flag. + * Handles the magic "last" value by looking up the cached cursor. + */ +export function resolveCursor( + cursorFlag: string | undefined, + contextKey: string +): string | undefined { + if (!cursorFlag) { + return; + } + if (cursorFlag === "last") { + const cached = getPaginationCursor(PAGINATION_KEY, contextKey); + if (!cached) { + throw new ContextError( + "Pagination cursor", + "No saved cursor for this query. Run without --cursor first." + ); + } + return cached; + } + return cursorFlag; +} + /** Result of resolving organizations to fetch projects from */ type OrgResolution = { orgs: string[]; @@ -143,25 +242,17 @@ type OrgResolution = { }; /** - * Resolve which organizations to fetch projects from. - * Uses CLI flag, config defaults, or DSN auto-detection. + * Resolve which organizations to fetch projects from (auto-detect mode). + * Uses config defaults or DSN auto-detection. */ -async function resolveOrgsToFetch( - orgFlag: string | undefined, - cwd: string -): Promise { - // 1. If org positional provided, use it directly - if (orgFlag) { - return { orgs: [orgFlag] }; - } - - // 2. Check config defaults +async function resolveOrgsForAutoDetect(cwd: string): Promise { + // 1. Check config defaults const defaultOrg = await getDefaultOrganization(); if (defaultOrg) { return { orgs: [defaultOrg] }; } - // 3. Auto-detect from DSNs (may find multiple in monorepos) + // 2. Auto-detect from DSNs (may find multiple in monorepos) try { const { targets, footer, skippedSelfHosted } = await resolveAllTargets({ cwd, @@ -169,46 +260,375 @@ async function resolveOrgsToFetch( if (targets.length > 0) { const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; - return { - orgs: uniqueOrgs, - footer, - skippedSelfHosted, - }; + return { orgs: uniqueOrgs, footer, skippedSelfHosted }; } - // No resolvable targets, but may have self-hosted DSNs return { orgs: [], skippedSelfHosted }; } catch (error) { - // Auth errors should propagate - user needs to log in if (error instanceof AuthError) { throw error; } - // Fall through to empty orgs for other errors (network, etc.) } return { orgs: [] }; } +/** Display projects in table format with header and rows */ +export function displayProjectTable( + stdout: Writer, + projects: ProjectWithOrg[] +): void { + const { orgWidth, slugWidth, nameWidth } = + calculateProjectColumnWidths(projects); + writeHeader(stdout, orgWidth, slugWidth, nameWidth); + writeRows({ stdout, projects, orgWidth, slugWidth, nameWidth }); +} + +/** + * Fetch a single page of projects from one org, with error handling + * that mirrors `fetchOrgProjectsSafe` — re-throws auth errors but + * silently returns empty for other failures (403, network errors). + */ +async function fetchPaginatedSafe( + org: string, + limit: number +): Promise<{ projects: ProjectWithOrg[]; nextCursor?: string }> { + try { + const response = await listProjectsPaginated(org, { perPage: limit }); + return { + projects: response.data.map((p) => ({ ...p, orgSlug: org })), + nextCursor: response.nextCursor, + }; + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + return { projects: [] }; + } +} + +/** + * Fetch projects for auto-detect mode. + * + * Optimization: when targeting a single org without platform filter, uses + * single-page pagination (`perPage=limit`) to avoid fetching all projects. + * Multi-org or filtered queries still require full fetch + client-side slicing. + */ +async function fetchAutoDetectProjects( + orgs: string[], + flags: ListFlags +): Promise<{ projects: ProjectWithOrg[]; nextCursor?: string }> { + if (orgs.length === 1 && !flags.platform) { + return fetchPaginatedSafe(orgs[0] as string, flags.limit); + } + if (orgs.length > 0) { + const results = await Promise.all(orgs.map(fetchOrgProjectsSafe)); + return { projects: results.flat() }; + } + return { projects: await fetchAllOrgProjects() }; +} + +/** Build a pagination hint for auto-detect JSON output. */ +function autoDetectPaginationHint(orgs: string[]): string { + return orgs.length === 1 + ? `sentry project list ${orgs[0]}/ --json` + : "sentry project list / --json"; +} + +/** + * Handle auto-detect mode: resolve orgs from config/DSN, fetch all projects, + * apply client-side filtering and limiting. + */ +export async function handleAutoDetect( + stdout: Writer, + cwd: string, + flags: ListFlags +): Promise { + const { + orgs: orgsToFetch, + footer, + skippedSelfHosted, + } = await resolveOrgsForAutoDetect(cwd); + + const { projects: allProjects, nextCursor } = await fetchAutoDetectProjects( + orgsToFetch, + flags + ); + + const filtered = filterByPlatform(allProjects, flags.platform); + const limitCount = + orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; + const limited = filtered.slice(0, limitCount); + + const hasMore = filtered.length > limited.length || !!nextCursor; + + if (flags.json) { + const output: Record = { data: limited, hasMore }; + if (hasMore) { + output.hint = autoDetectPaginationHint(orgsToFetch); + } + writeJson(stdout, output); + return; + } + + if (limited.length === 0) { + stdout.write("No projects found.\n"); + writeSelfHostedWarning(stdout, skippedSelfHosted); + return; + } + + displayProjectTable(stdout, limited); + + if (hasMore) { + if (nextCursor && orgsToFetch.length === 1) { + const org = orgsToFetch[0] as string; + stdout.write( + `\nShowing ${limited.length} projects (more available). ` + + `Use 'sentry project list ${org}/' for paginated results.\n` + ); + } else { + stdout.write( + `\nShowing ${limited.length} projects (more available). Use --limit to show more.\n` + ); + } + } + + if (footer) { + stdout.write(`\n${footer}\n`); + } + writeSelfHostedWarning(stdout, skippedSelfHosted); + writeFooter( + stdout, + "Tip: Use 'sentry project view /' for details" + ); +} + +/** + * Handle explicit org/project targeting (e.g., sentry/sentry). + * Fetches the specific project directly via the API. + */ +export async function handleExplicit( + stdout: Writer, + org: string, + projectSlug: string, + flags: ListFlags +): Promise { + let project: ProjectWithOrg; + try { + const result = await getProject(org, projectSlug); + project = { ...result, orgSlug: org }; + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + if (flags.json) { + writeJson(stdout, []); + return; + } + stdout.write( + `No project '${projectSlug}' found in organization '${org}'.\n` + ); + writeFooter( + stdout, + `Tip: Use 'sentry project list ${org}/' to see all projects` + ); + return; + } + + const filtered = filterByPlatform([project], flags.platform); + + if (flags.json) { + writeJson(stdout, filtered); + return; + } + + if (filtered.length === 0) { + stdout.write( + `No project '${projectSlug}' found matching platform '${flags.platform}'.\n` + ); + return; + } + + displayProjectTable(stdout, filtered); + writeFooter( + stdout, + `Tip: Use 'sentry project view ${org}/${projectSlug}' for details` + ); +} + +export type OrgAllOptions = { + stdout: Writer; + org: string; + flags: ListFlags; + contextKey: string; + cursor: string | undefined; +}; + +/** Build the CLI hint for fetching the next page, preserving active flags. */ +function nextPageHint(org: string, platform?: string): string { + const base = `sentry project list ${org}/ -c last`; + return platform ? `${base} --platform ${platform}` : base; +} + +/** + * Handle org-all mode (e.g., sentry/). + * Uses cursor pagination for efficient page-by-page listing. + */ +export async function handleOrgAll(options: OrgAllOptions): Promise { + const { stdout, org, flags, contextKey, cursor } = options; + const response: PaginatedResponse = + await listProjectsPaginated(org, { + cursor, + perPage: flags.limit, + }); + + const projects: ProjectWithOrg[] = response.data.map((p) => ({ + ...p, + orgSlug: org, + })); + + const filtered = filterByPlatform(projects, flags.platform); + + const hasMore = !!response.nextCursor; + + // Update cursor cache for `--cursor last` support + if (response.nextCursor) { + setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor); + } else { + clearPaginationCursor(PAGINATION_KEY, contextKey); + } + + if (flags.json) { + const output = hasMore + ? { data: filtered, nextCursor: response.nextCursor, hasMore: true } + : { data: filtered, hasMore: false }; + writeJson(stdout, output); + return; + } + + if (filtered.length === 0) { + if (hasMore) { + stdout.write( + `No matching projects on this page. Try the next page: ${nextPageHint(org, flags.platform)}\n` + ); + } else if (flags.platform) { + stdout.write( + `No projects matching platform '${flags.platform}' in organization '${org}'.\n` + ); + } else { + stdout.write(`No projects found in organization '${org}'.\n`); + } + return; + } + + displayProjectTable(stdout, filtered); + + if (hasMore) { + stdout.write(`\nShowing ${filtered.length} projects (more available)\n`); + stdout.write(`Next page: ${nextPageHint(org, flags.platform)}\n`); + } else { + stdout.write(`\nShowing ${filtered.length} projects\n`); + } + + writeFooter( + stdout, + "Tip: Use 'sentry project view /' for details" + ); +} + +/** + * Handle project-search mode (bare slug, e.g., "sentry"). + * Searches for the project across all accessible organizations. + */ +export async function handleProjectSearch( + stdout: Writer, + projectSlug: string, + flags: ListFlags +): Promise { + const projects: ProjectWithOrg[] = await findProjectsBySlug(projectSlug); + const filtered = filterByPlatform(projects, flags.platform); + + if (filtered.length === 0) { + if (flags.json) { + writeJson(stdout, []); + return; + } + if (projects.length > 0 && flags.platform) { + stdout.write( + `No project '${projectSlug}' found matching platform '${flags.platform}'.\n` + ); + return; + } + throw new ContextError( + "Project", + `No project '${projectSlug}' found in any accessible organization.\n\n` + + `Try: sentry project list /${projectSlug}` + ); + } + + const limited = filtered.slice(0, flags.limit); + + if (flags.json) { + writeJson(stdout, limited); + return; + } + + displayProjectTable(stdout, limited); + + if (filtered.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${filtered.length} matches. Use --limit to show more.\n` + ); + } else if (limited.length > 1) { + stdout.write( + `\nFound '${projectSlug}' in ${limited.length} organizations\n` + ); + } + + writeFooter( + stdout, + "Tip: Use 'sentry project view /' for details" + ); +} + +/** Write self-hosted DSN warning if applicable */ +export function writeSelfHostedWarning( + stdout: Writer, + skippedSelfHosted: number | undefined +): void { + if (skippedSelfHosted) { + stdout.write( + `\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` + + "Specify the organization explicitly: sentry project list /\n" + ); + } +} + export const listCommand = buildCommand({ docs: { brief: "List projects", fullDescription: - "List projects in an organization. If no organization is specified, " + - "uses the default organization or lists projects from all accessible organizations.\n\n" + - "Examples:\n" + - " sentry project list # auto-detect or list all\n" + - " sentry project list my-org # list projects in my-org\n" + - " sentry project list --limit 10\n" + - " sentry project list --json\n" + - " sentry project list --platform javascript", + "List projects in an organization.\n\n" + + "Target specification:\n" + + " sentry project list # auto-detect from DSN or config\n" + + " sentry project list / # list all projects in org (paginated)\n" + + " sentry project list / # show specific project\n" + + " sentry project list # find project across all orgs\n\n" + + "Pagination:\n" + + " sentry project list / -c last # continue from last page\n" + + " sentry project list / -c # resume at specific cursor\n\n" + + "Filtering and output:\n" + + " sentry project list --platform javascript # filter by platform\n" + + " sentry project list --limit 50 # show more results\n" + + " sentry project list --json # output as JSON", }, parameters: { positional: { kind: "tuple", parameters: [ { - placeholder: "org", - brief: "Organization slug (optional)", + placeholder: "target", + brief: "Target: /, /, or ", parse: String, optional: true, }, @@ -227,6 +647,12 @@ export const listCommand = buildCommand({ brief: "Output JSON", default: false, }, + cursor: { + kind: "parsed", + parse: String, + brief: 'Pagination cursor (use "last" to continue from previous page)', + optional: true, + }, platform: { kind: "parsed", parse: String, @@ -234,82 +660,57 @@ export const listCommand = buildCommand({ optional: true, }, }, - aliases: { n: "limit", p: "platform" }, + aliases: { n: "limit", p: "platform", c: "cursor" }, }, async func( this: SentryContext, flags: ListFlags, - org?: string + target?: string ): Promise { const { stdout, cwd } = this; - // Resolve which organizations to fetch from - const { - orgs: orgsToFetch, - footer, - skippedSelfHosted, - } = await resolveOrgsToFetch(org, cwd); - - // Fetch projects from all orgs (or all accessible if none detected) - let allProjects: ProjectWithOrg[]; - if (orgsToFetch.length > 0) { - const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); - allProjects = results.flat(); - } else { - allProjects = await fetchAllOrgProjects(); - } - - // Filter and limit (limit is per-org when multiple orgs) - const filtered = filterByPlatform(allProjects, flags.platform); - const limitCount = - orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; - const limited = filtered.slice(0, limitCount); + const parsed = parseOrgProjectArg(target); - if (flags.json) { - writeJson(stdout, limited); - return; + // Cursor pagination is only supported in org-all mode — check before resolving + if (flags.cursor && parsed.type !== "org-all") { + throw new ValidationError( + "The --cursor flag is only supported when listing projects for a specific organization " + + "(e.g., sentry project list /). " + + "Use 'sentry project list /' for paginated results.", + "cursor" + ); } - if (limited.length === 0) { - const msg = - orgsToFetch.length === 1 - ? `No projects found in organization '${orgsToFetch[0]}'.\n` - : "No projects found.\n"; - stdout.write(msg); - return; - } + const contextKey = buildContextKey(parsed, flags, getApiBaseUrl()); + const cursor = resolveCursor(flags.cursor, contextKey); - const { orgWidth, slugWidth, nameWidth } = - calculateProjectColumnWidths(limited); - writeHeader(stdout, orgWidth, slugWidth, nameWidth); - writeRows({ - stdout, - projects: limited, - orgWidth, - slugWidth, - nameWidth, - }); + switch (parsed.type) { + case "auto-detect": + await handleAutoDetect(stdout, cwd, flags); + break; - if (filtered.length > limited.length) { - stdout.write( - `\nShowing ${limited.length} of ${filtered.length} projects\n` - ); - } + case "explicit": + await handleExplicit(stdout, parsed.org, parsed.project, flags); + break; - if (footer) { - stdout.write(`\n${footer}\n`); - } + case "org-all": + await handleOrgAll({ + stdout, + org: parsed.org, + flags, + contextKey, + cursor, + }); + break; - if (skippedSelfHosted) { - stdout.write( - `\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` + - "Specify the organization explicitly: sentry project list \n" - ); - } + case "project-search": + await handleProjectSearch(stdout, parsed.projectSlug, flags); + break; - writeFooter( - stdout, - "Tip: Use 'sentry project view /' for details" - ); + default: { + const _exhaustiveCheck: never = parsed; + throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); + } + } }, }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index f79841a8..3e095fca 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -10,7 +10,6 @@ import { listAnOrganization_sIssues, - listAnOrganization_sProjects, listAnOrganization_sTeams, listAProject_sClientKeys, queryExploreEventsInTableFormat, @@ -171,19 +170,98 @@ async function getOrgSdkConfig(orgSlug: string) { // Raw request functions (for internal/generic endpoints) +/** + * Extract the value of a named attribute from a Link header segment. + * Parses `key="value"` pairs using string operations instead of regex + * for robustness and performance. + * + * @param segment - A single Link header segment (e.g., `; rel="next"; cursor="abc"`) + * @param attr - The attribute name to extract (e.g., "rel", "cursor") + * @returns The attribute value, or undefined if not found + */ +function extractLinkAttr(segment: string, attr: string): string | undefined { + const prefix = `${attr}="`; + const start = segment.indexOf(prefix); + if (start === -1) { + return; + } + const valueStart = start + prefix.length; + const end = segment.indexOf('"', valueStart); + if (end === -1) { + return; + } + return segment.slice(valueStart, end); +} + +/** + * Maximum number of pages to follow when auto-paginating. + * + * Safety limit to prevent runaway pagination when the API returns an unexpectedly + * large number of pages. At 100 items/page this allows up to 5,000 items, which + * covers even the largest organizations. Override with SENTRY_MAX_PAGINATION_PAGES + * env var for edge cases. + */ +const MAX_PAGINATION_PAGES = Math.max( + 1, + Number(process.env.SENTRY_MAX_PAGINATION_PAGES) || 50 +); + +/** + * Paginated API response with cursor metadata. + * More pages exist when `nextCursor` is defined. + */ +export type PaginatedResponse = { + /** The response data */ + data: T; + /** Cursor for fetching the next page (undefined if no more pages) */ + nextCursor?: string; +}; + +/** + * Parse Sentry's RFC 5988 Link response header to extract pagination cursors. + * + * Sentry Link header format: + * `; rel="next"; results="true"; cursor="1735689600000:0:0"` + * + * @param header - Raw Link header string + * @returns Parsed pagination info with next cursor if available + */ +export function parseLinkHeader(header: string | null): { + nextCursor?: string; +} { + if (!header) { + return {}; + } + + // Split on comma to get individual link entries + for (const part of header.split(",")) { + const rel = extractLinkAttr(part, "rel"); + const results = extractLinkAttr(part, "results"); + const cursor = extractLinkAttr(part, "cursor"); + + if (rel === "next" && results === "true" && cursor) { + return { nextCursor: cursor }; + } + } + + return {}; +} + /** * Make an authenticated request to a specific Sentry region. + * Returns both parsed response data and raw headers for pagination support. * Used for internal endpoints not covered by @sentry/api SDK functions. * * @param regionUrl - The region's base URL (e.g., https://us.sentry.io) * @param endpoint - API endpoint path (e.g., "/users/me/regions/") * @param options - Request options + * @returns Parsed data and response headers */ export async function apiRequestToRegion( regionUrl: string, endpoint: string, options: ApiRequestOptions = {} -): Promise { +): Promise<{ data: T; headers: Headers }> { const { method = "GET", body, params, schema } = options; const config = getSdkConfig(regionUrl); @@ -226,12 +304,9 @@ export async function apiRequestToRegion( } const data = await response.json(); + const validated = schema ? schema.parse(data) : (data as T); - if (schema) { - return schema.parse(data); - } - - return data as T; + return { data: validated, headers: response.headers }; } /** @@ -243,11 +318,16 @@ export async function apiRequestToRegion( * @throws {AuthError} When not authenticated * @throws {ApiError} On API errors */ -export function apiRequest( +export async function apiRequest( endpoint: string, options: ApiRequestOptions = {} ): Promise { - return apiRequestToRegion(getApiBaseUrl(), endpoint, options); + const { data } = await apiRequestToRegion( + getApiBaseUrl(), + endpoint, + options + ); + return data; } /** @@ -326,12 +406,12 @@ export async function rawApiRequest( */ export async function getUserRegions(): Promise { // /users/me/regions/ is an internal endpoint - use raw request - const response = await apiRequestToRegion( + const { data } = await apiRequestToRegion( getControlSiloUrl(), "/users/me/regions/", { schema: UserRegionsResponseSchema } ); - return response.regions; + return data.regions; } /** @@ -353,6 +433,111 @@ export async function listOrganizationsInRegion( return data as unknown as SentryOrganization[]; } +// Pagination infrastructure for raw API endpoints + +/** Regex patterns for extracting org slugs from endpoint paths */ +const ORG_ENDPOINT_REGEX = /\/organizations\/([^/]+)\//; +const PROJECT_ENDPOINT_REGEX = /\/projects\/([^/]+)\//; + +/** + * Extract organization slug from an endpoint path. + * Supports: + * - `/organizations/{slug}/...` - standard organization endpoints + * - `/projects/{org}/{project}/...` - project-scoped endpoints + */ +function extractOrgSlugFromEndpoint(endpoint: string): string | null { + const orgMatch = endpoint.match(ORG_ENDPOINT_REGEX); + if (orgMatch?.[1]) { + return orgMatch[1]; + } + + const projectMatch = endpoint.match(PROJECT_ENDPOINT_REGEX); + if (projectMatch?.[1]) { + return projectMatch[1]; + } + + return null; +} + +/** + * Make an org-scoped API request that returns pagination metadata. + * Used for single-page fetches where the caller needs cursor info. + * + * The endpoint must contain the org slug in the path (e.g., `/organizations/{slug}/...`). + * The org slug is extracted to look up the correct region URL. + * + * @param endpoint - API endpoint path containing the org slug + * @param options - Request options + * @returns Response data with pagination cursor metadata + */ +async function orgScopedRequestPaginated( + endpoint: string, + options: ApiRequestOptions = {} +): Promise> { + const orgSlug = extractOrgSlugFromEndpoint(endpoint); + if (!orgSlug) { + throw new Error( + `Cannot extract org slug from endpoint: ${endpoint}. ` + + "Endpoint must match /organizations/{slug}/..." + ); + } + const regionUrl = await resolveOrgRegion(orgSlug); + const { data, headers } = await apiRequestToRegion( + regionUrl, + endpoint, + options + ); + const { nextCursor } = parseLinkHeader(headers.get("link")); + return { data, nextCursor }; +} + +/** + * Auto-paginate through all pages of an org-scoped API endpoint. + * Follows cursor links until no more results or the safety limit is reached. + * + * @param endpoint - API endpoint path containing the org slug + * @param options - Request options (schema must validate an array type) + * @param perPage - Number of items per API page (default: 100) + * @returns Combined array of all results across all pages + */ +async function orgScopedPaginateAll( + endpoint: string, + options: ApiRequestOptions, + perPage = 100 +): Promise { + const allResults: T[] = []; + let cursor: string | undefined; + let truncated = false; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const params = { ...options.params, per_page: perPage, cursor }; + const response = await orgScopedRequestPaginated(endpoint, { + ...options, + params, + }); + allResults.push(...response.data); + + if (!response.nextCursor) { + break; + } + cursor = response.nextCursor; + + // Detect if we're about to exit due to the safety limit + if (page === MAX_PAGINATION_PAGES - 1) { + truncated = true; + } + } + + if (truncated) { + console.error( + `Warning: Pagination limit reached (${MAX_PAGINATION_PAGES} pages, ${allResults.length} items). ` + + "Results may be incomplete for this organization." + ); + } + + return allResults; +} + /** * List all organizations the user has access to across all regions. * Performs a fan-out to each region and combines results. @@ -424,19 +609,42 @@ export async function getOrganization( // Project functions /** - * List projects in an organization. + * List all projects in an organization. + * Automatically paginates through all API pages to return the complete list. * Uses region-aware routing for multi-region support. + * + * @param orgSlug - Organization slug + * @returns All projects in the organization */ -export async function listProjects(orgSlug: string): Promise { - const config = await getOrgSdkConfig(orgSlug); - - const result = await listAnOrganization_sProjects({ - ...config, - path: { organization_id_or_slug: orgSlug }, - }); +export function listProjects(orgSlug: string): Promise { + return orgScopedPaginateAll( + `/organizations/${orgSlug}/projects/`, + {} + ); +} - const data = unwrapResult(result, "Failed to list projects"); - return data as unknown as SentryProject[]; +/** + * List projects in an organization with pagination control. + * Returns a single page of results with cursor metadata for manual pagination. + * Uses region-aware routing for multi-region support. + * + * @param orgSlug - Organization slug + * @param options - Pagination options + * @returns Single page of projects with cursor metadata + */ +export function listProjectsPaginated( + orgSlug: string, + options: { cursor?: string; perPage?: number } = {} +): Promise> { + return orgScopedRequestPaginated( + `/organizations/${orgSlug}/projects/`, + { + params: { + per_page: options.perPage ?? 100, + cursor: options.cursor, + }, + } + ); } /** Project with its organization context */ @@ -454,10 +662,11 @@ export async function listRepositories( ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); - return apiRequestToRegion( + const { data } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/repos/` ); + return data; } /** @@ -490,19 +699,22 @@ export async function findProjectsBySlug( ): Promise { const orgs = await listOrganizations(); + // Direct lookup in parallel — one API call per org instead of paginating all projects const searchResults = await Promise.all( orgs.map(async (org) => { try { - const projects = await listProjects(org.slug); - const match = projects.find((p) => p.slug === projectSlug); - if (match) { - return { ...match, orgSlug: org.slug }; + const project = await getProject(org.slug, projectSlug); + // The API accepts project_id_or_slug, so a numeric input could + // resolve by ID. Verify the returned slug actually matches. + if (project.slug !== projectSlug) { + return null; } - return null; + return { ...project, orgSlug: org.slug }; } catch (error) { if (error instanceof AuthError) { throw error; } + // 404 or permission errors — project doesn't exist in this org return null; } }) @@ -605,7 +817,7 @@ export async function findProjectByDsnKey( if (regions.length === 0) { // Fall back to default region for self-hosted // This uses an internal query parameter not in the public API - const projects = await apiRequestToRegion( + const { data: projects } = await apiRequestToRegion( getApiBaseUrl(), "/projects/", { params: { query: `dsn:${publicKey}` } } @@ -616,11 +828,12 @@ export async function findProjectByDsnKey( const results = await Promise.all( regions.map(async (region) => { try { - return await apiRequestToRegion( + const { data } = await apiRequestToRegion( region.url, "/projects/", { params: { query: `dsn:${publicKey}` } } ); + return data; } catch { return []; } @@ -833,7 +1046,7 @@ export async function getDetailedTrace( ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); - return apiRequestToRegion( + const { data } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/trace/${traceId}/`, { @@ -844,6 +1057,7 @@ export async function getDetailedTrace( }, } ); + return data; } /** Fields to request from the transactions API */ @@ -892,7 +1106,7 @@ export async function listTransactions( const regionUrl = await resolveOrgRegion(orgSlug); // Use raw request: the SDK's dataset type doesn't include "transactions" - const response = await apiRequestToRegion( + const { data: response } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/events/`, { @@ -1005,7 +1219,7 @@ export async function triggerSolutionPlanning( ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); - return apiRequestToRegion( + const { data } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/issues/${issueId}/autofix/`, { @@ -1016,6 +1230,7 @@ export async function triggerSolutionPlanning( }, } ); + return data; } // User functions @@ -1024,10 +1239,13 @@ export async function triggerSolutionPlanning( * Get the currently authenticated user's information. * Uses the /users/me/ endpoint on the control silo. */ -export function getCurrentUser(): Promise { - return apiRequestToRegion(getControlSiloUrl(), "/users/me/", { - schema: SentryUserSchema, - }); +export async function getCurrentUser(): Promise { + const { data } = await apiRequestToRegion( + getControlSiloUrl(), + "/users/me/", + { schema: SentryUserSchema } + ); + return data; } // Log functions diff --git a/src/lib/db/auth.ts b/src/lib/db/auth.ts index 8a1657aa..f426de95 100644 --- a/src/lib/db/auth.ts +++ b/src/lib/db/auth.ts @@ -98,9 +98,10 @@ export function clearAuth(): void { withDbSpan("clearAuth", () => { const db = getDatabase(); db.query("DELETE FROM auth WHERE id = 1").run(); - // Also clear user info and org region cache when logging out + // Also clear user info, org region cache, and pagination cursors when logging out db.query("DELETE FROM user_info WHERE id = 1").run(); db.query("DELETE FROM org_regions").run(); + db.query("DELETE FROM pagination_cursors").run(); }); } diff --git a/src/lib/db/pagination.ts b/src/lib/db/pagination.ts new file mode 100644 index 00000000..06d26680 --- /dev/null +++ b/src/lib/db/pagination.ts @@ -0,0 +1,99 @@ +/** + * Pagination cursor storage for `--cursor last` support. + * + * Stores the most recent "next page" cursor for each (command, context) pair, + * using a composite primary key so different contexts (e.g., different orgs) + * maintain independent cursors. + * Cursors expire after a short TTL to prevent stale pagination. + */ + +import { getDatabase } from "./index.js"; +import { runUpsert } from "./utils.js"; + +/** Default TTL for stored cursors: 5 minutes */ +const CURSOR_TTL_MS = 5 * 60 * 1000; + +type PaginationCursorRow = { + command_key: string; + cursor: string; + context: string; + expires_at: number; +}; + +/** + * Get a stored pagination cursor if it exists and hasn't expired. + * + * @param commandKey - Command identifier (e.g., "project-list") + * @param context - Serialized query context for lookup + * @returns The stored cursor string, or undefined if not found/expired + */ +export function getPaginationCursor( + commandKey: string, + context: string +): string | undefined { + const db = getDatabase(); + const row = db + .query( + "SELECT cursor, expires_at FROM pagination_cursors WHERE command_key = ? AND context = ?" + ) + .get(commandKey, context) as PaginationCursorRow | undefined; + + if (!row) { + return; + } + + // Check expiry + if (row.expires_at <= Date.now()) { + db.query( + "DELETE FROM pagination_cursors WHERE command_key = ? AND context = ?" + ).run(commandKey, context); + return; + } + + return row.cursor; +} + +/** + * Store a pagination cursor for later retrieval via `--cursor last`. + * + * @param commandKey - Command identifier (e.g., "project-list") + * @param context - Serialized query context for lookup + * @param cursor - The cursor string to store + * @param ttlMs - Time-to-live in milliseconds (default: 5 minutes) + */ +export function setPaginationCursor( + commandKey: string, + context: string, + cursor: string, + ttlMs = CURSOR_TTL_MS +): void { + const db = getDatabase(); + runUpsert( + db, + "pagination_cursors", + { + command_key: commandKey, + context, + cursor, + expires_at: Date.now() + ttlMs, + }, + ["command_key", "context"] + ); +} + +/** + * Remove the stored pagination cursor for a command and context. + * Called when a non-paginated result is displayed (no more pages). + * + * @param commandKey - Command identifier (e.g., "project-list") + * @param context - Serialized query context to clear + */ +export function clearPaginationCursor( + commandKey: string, + context: string +): void { + const db = getDatabase(); + db.query( + "DELETE FROM pagination_cursors WHERE command_key = ? AND context = ?" + ).run(commandKey, context); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 8f72a0ab..fef39bfa 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -13,7 +13,7 @@ import type { Database } from "bun:sqlite"; -export const CURRENT_SCHEMA_VERSION = 4; +export const CURRENT_SCHEMA_VERSION = 5; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -32,6 +32,13 @@ export type ColumnDef = { export type TableSchema = { columns: Record; + /** + * Composite primary key columns. When set, the DDL generator emits a + * table-level `PRIMARY KEY (col1, col2, ...)` constraint instead of + * per-column `PRIMARY KEY` attributes. Individual columns listed here + * should NOT also set `primaryKey: true`. + */ + compositePrimaryKey?: string[]; }; /** @@ -137,6 +144,15 @@ export const TABLE_SCHEMAS: Record = { }, }, }, + pagination_cursors: { + columns: { + command_key: { type: "TEXT", notNull: true }, + context: { type: "TEXT", notNull: true }, + cursor: { type: "TEXT", notNull: true }, + expires_at: { type: "INTEGER", notNull: true }, + }, + compositePrimaryKey: ["command_key", "context"], + }, metadata: { columns: { key: { type: "TEXT", primaryKey: true }, @@ -198,7 +214,8 @@ export const TABLE_SCHEMAS: Record = { /** Generate CREATE TABLE DDL from column definitions */ function columnDefsToDDL( tableName: string, - columns: [string, ColumnDef][] + columns: [string, ColumnDef][], + compositePrimaryKey?: string[] ): string { const columnDefs = columns.map(([name, col]) => { const parts = [name, col.type]; @@ -217,6 +234,10 @@ function columnDefsToDDL( return parts.join(" "); }); + if (compositePrimaryKey && compositePrimaryKey.length > 0) { + columnDefs.push(`PRIMARY KEY (${compositePrimaryKey.join(", ")})`); + } + return `CREATE TABLE IF NOT EXISTS ${tableName} (\n ${columnDefs.join(",\n ")}\n )`; } @@ -225,7 +246,11 @@ export function generateTableDDL( tableName: string, schema: TableSchema ): string { - return columnDefsToDDL(tableName, Object.entries(schema.columns)); + return columnDefsToDDL( + tableName, + Object.entries(schema.columns), + schema.compositePrimaryKey + ); } /** @@ -250,7 +275,7 @@ export function generatePreMigrationTableDDL(tableName: string): string { ); } - return columnDefsToDDL(tableName, baseColumns); + return columnDefsToDDL(tableName, baseColumns, schema.compositePrimaryKey); } /** Generated DDL statements for all tables (used for repair and init) */ @@ -509,7 +534,6 @@ export function tryRepairAndRetry( } export function initSchema(db: Database): void { - // Generate combined DDL from all table schemas const ddlStatements = Object.values(EXPECTED_TABLES).join(";\n\n"); db.exec(ddlStatements); @@ -569,6 +593,11 @@ export function runMigrations(db: Database): void { db.exec(EXPECTED_TABLES.project_root_cache as string); } + // Migration 4 -> 5: Add pagination_cursors table for --cursor last support + if (currentVersion < 5) { + db.exec(EXPECTED_TABLES.pagination_cursors as string); + } + if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION diff --git a/test/commands/cli/fix.test.ts b/test/commands/cli/fix.test.ts index 029949fd..d84da6d2 100644 --- a/test/commands/cli/fix.test.ts +++ b/test/commands/cli/fix.test.ts @@ -49,9 +49,8 @@ function createDatabaseWithMissingTables( const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { - if (!missingTables.includes(tableName)) { - statements.push(EXPECTED_TABLES[tableName] as string); - } + if (missingTables.includes(tableName)) continue; + statements.push(EXPECTED_TABLES[tableName] as string); } db.exec(statements.join(";\n")); diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 69306281..13241e9b 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -355,18 +355,15 @@ describe("resolveOrgAndIssueId", () => { ); } - // listProjects for my-org - if (url.includes("organizations/my-org/projects/")) { + // getProject for my-org/craft - found + if (url.includes("/projects/my-org/craft/")) { return new Response( - JSON.stringify([ - { id: "123", slug: "craft", name: "Craft", platform: "javascript" }, - { - id: "456", - slug: "other-project", - name: "Other", - platform: "python", - }, - ]), + JSON.stringify({ + id: "123", + slug: "craft", + name: "Craft", + platform: "javascript", + }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -448,7 +445,16 @@ describe("resolveOrgAndIssueId", () => { ); } - // listProjects - return projects that don't match "nonexistent" + // getProject (single project detail) — "nonexistent" doesn't exist + // URL pattern: /projects/{org}/{project}/ + if (url.match(/\/projects\/[^/]+\/[^/]+/)) { + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + } + + // listProjects — return projects that don't match "nonexistent" + // URL pattern: /organizations/{org}/projects/ if (url.includes("/projects/")) { return new Response( JSON.stringify([ @@ -520,17 +526,15 @@ describe("resolveOrgAndIssueId", () => { ); } - // listProjects for org1 - has "common" project - if (url.includes("organizations/org1/projects/")) { + // getProject for org1/common - found + if (url.includes("/projects/org1/common/")) { return new Response( - JSON.stringify([ - { - id: "123", - slug: "common", - name: "Common", - platform: "javascript", - }, - ]), + JSON.stringify({ + id: "123", + slug: "common", + name: "Common", + platform: "javascript", + }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -538,12 +542,15 @@ describe("resolveOrgAndIssueId", () => { ); } - // listProjects for org2 - also has "common" project - if (url.includes("organizations/org2/projects/")) { + // getProject for org2/common - also found + if (url.includes("/projects/org2/common/")) { return new Response( - JSON.stringify([ - { id: "456", slug: "common", name: "Common", platform: "python" }, - ]), + JSON.stringify({ + id: "456", + slug: "common", + name: "Common", + platform: "python", + }), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts new file mode 100644 index 00000000..dd72744c --- /dev/null +++ b/test/commands/project/list.test.ts @@ -0,0 +1,1412 @@ +/** + * Unit Tests for Project List Command + * + * Tests the exported helper functions and handler functions. + * Handlers are tested with fetch mocking for API isolation. + */ + +// biome-ignore-all lint/suspicious/noMisplacedAssertion: Property tests use expect() inside fast-check callbacks. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + assert as fcAssert, + property, + tuple, +} from "fast-check"; +import { + buildContextKey, + displayProjectTable, + fetchAllOrgProjects, + fetchOrgProjects, + fetchOrgProjectsSafe, + filterByPlatform, + handleAutoDetect, + handleExplicit, + handleOrgAll, + handleProjectSearch, + PAGINATION_KEY, + resolveCursor, + writeHeader, + writeRows, + writeSelfHostedWarning, +} from "../../../src/commands/project/list.js"; +import type { ParsedOrgProject } from "../../../src/lib/arg-parsing.js"; +import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; +import { clearAuth, setAuthToken } from "../../../src/lib/db/auth.js"; +import { setDefaults } from "../../../src/lib/db/defaults.js"; +import { CONFIG_DIR_ENV_VAR } from "../../../src/lib/db/index.js"; +import { + getPaginationCursor, + setPaginationCursor, +} from "../../../src/lib/db/pagination.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { AuthError, ContextError } from "../../../src/lib/errors.js"; +import type { SentryProject, Writer } from "../../../src/types/index.js"; +import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +// Test config directory for DB-dependent tests +let testConfigDir: string; + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-project-list-", { + isolateProjectRoot: true, + }); + process.env[CONFIG_DIR_ENV_VAR] = testConfigDir; +}); + +afterEach(async () => { + await cleanupTestDir(testConfigDir); +}); + +/** Capture stdout writes */ +function createCapture(): { writer: Writer; output: () => string } { + const chunks: string[] = []; + return { + writer: { + write: (s: string) => { + chunks.push(s); + return true; + }, + } as Writer, + output: () => chunks.join(""), + }; +} + +/** Create a minimal project for testing */ +function makeProject( + overrides: Partial & { orgSlug?: string } = {} +): SentryProject & { orgSlug?: string } { + return { + id: "1", + slug: "test-project", + name: "Test Project", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + ...overrides, + }; +} + +// Arbitraries + +const slugArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789".split("")), + { + minLength: 1, + maxLength: 12, + } +).map((chars) => chars.join("")); + +const platformArb = constantFrom( + "javascript", + "python", + "go", + "java", + "ruby", + "php", + "javascript-react", + "python-django" +); + +// Tests + +describe("buildContextKey", () => { + const host = "https://sentry.io"; + + test("org-all mode produces host:|type:org:", () => { + fcAssert( + property(slugArb, (org) => { + const parsed: ParsedOrgProject = { type: "org-all", org }; + const key = buildContextKey(parsed, {}, host); + expect(key).toBe(`host:${host}|type:org:${org}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("auto-detect mode produces host + type:auto", () => { + const parsed: ParsedOrgProject = { type: "auto-detect" }; + expect(buildContextKey(parsed, {}, host)).toBe(`host:${host}|type:auto`); + }); + + test("explicit mode produces host + type:explicit:/", () => { + fcAssert( + property(tuple(slugArb, slugArb), ([org, project]) => { + const parsed: ParsedOrgProject = { type: "explicit", org, project }; + const key = buildContextKey(parsed, {}, host); + expect(key).toBe(`host:${host}|type:explicit:${org}/${project}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("project-search mode produces host + type:search:", () => { + fcAssert( + property(slugArb, (projectSlug) => { + const parsed: ParsedOrgProject = { + type: "project-search", + projectSlug, + }; + const key = buildContextKey(parsed, {}, host); + expect(key).toBe(`host:${host}|type:search:${projectSlug}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("platform flag is appended with pipe separator", () => { + fcAssert( + property(tuple(slugArb, platformArb), ([org, platform]) => { + const parsed: ParsedOrgProject = { type: "org-all", org }; + const key = buildContextKey(parsed, { platform }, host); + expect(key).toBe(`host:${host}|type:org:${org}|platform:${platform}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("different hosts produce different keys for same org", () => { + fcAssert( + property(slugArb, (org) => { + const parsed: ParsedOrgProject = { type: "org-all", org }; + const saas = buildContextKey(parsed, {}, "https://sentry.io"); + const selfHosted = buildContextKey( + parsed, + {}, + "https://sentry.example.com" + ); + expect(saas).not.toBe(selfHosted); + expect(saas).toStartWith("host:https://sentry.io|"); + expect(selfHosted).toStartWith("host:https://sentry.example.com|"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("filterByPlatform", () => { + test("no platform returns all projects", () => { + const projects = [ + makeProject({ platform: "javascript" }), + makeProject({ platform: "python" }), + ]; + expect(filterByPlatform(projects)).toHaveLength(2); + expect(filterByPlatform(projects, undefined)).toHaveLength(2); + }); + + test("case-insensitive partial match", () => { + const projects = [ + makeProject({ slug: "web", platform: "javascript-react" }), + makeProject({ slug: "api", platform: "python-django" }), + makeProject({ slug: "cli", platform: "javascript" }), + ]; + + // Partial match + expect(filterByPlatform(projects, "javascript")).toHaveLength(2); + expect(filterByPlatform(projects, "python")).toHaveLength(1); + + // Case-insensitive + expect(filterByPlatform(projects, "JAVASCRIPT")).toHaveLength(2); + expect(filterByPlatform(projects, "Python")).toHaveLength(1); + }); + + test("no match returns empty array", () => { + const projects = [makeProject({ platform: "javascript" })]; + expect(filterByPlatform(projects, "rust")).toHaveLength(0); + }); + + test("null platform in project is not matched", () => { + const projects = [makeProject({ platform: null as unknown as string })]; + expect(filterByPlatform(projects, "javascript")).toHaveLength(0); + }); + + test("property: filtering is idempotent", () => { + fcAssert( + property(platformArb, (platform) => { + const projects = [ + makeProject({ slug: "a", platform: "javascript-react" }), + makeProject({ slug: "b", platform: "python-django" }), + makeProject({ slug: "c", platform: "go" }), + ]; + const once = filterByPlatform(projects, platform); + const twice = filterByPlatform(once, platform); + expect(twice).toEqual(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("property: filtered result is subset of input", () => { + fcAssert( + property(platformArb, (platform) => { + const projects = [ + makeProject({ slug: "a", platform: "javascript" }), + makeProject({ slug: "b", platform: "python" }), + makeProject({ slug: "c", platform: "go" }), + ]; + const filtered = filterByPlatform(projects, platform); + expect(filtered.length).toBeLessThanOrEqual(projects.length); + for (const p of filtered) { + expect(projects).toContain(p); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("resolveCursor", () => { + test("undefined cursor returns undefined", () => { + expect(resolveCursor(undefined, "org:sentry")).toBeUndefined(); + }); + + test("explicit cursor value is passed through", () => { + expect(resolveCursor("1735689600000:100:0", "org:sentry")).toBe( + "1735689600000:100:0" + ); + }); + + test("'last' with no cached cursor throws ContextError", () => { + expect(() => resolveCursor("last", "org:sentry")).toThrow(ContextError); + expect(() => resolveCursor("last", "org:sentry")).toThrow( + /No saved cursor/ + ); + }); + + test("'last' with cached cursor returns the cached value", () => { + const cursor = "1735689600000:100:0"; + const contextKey = "org:test-resolve"; + setPaginationCursor(PAGINATION_KEY, contextKey, cursor, 300_000); + + const result = resolveCursor("last", contextKey); + expect(result).toBe(cursor); + }); + + test("'last' with expired cursor throws ContextError", () => { + const contextKey = "org:test-expired"; + setPaginationCursor(PAGINATION_KEY, contextKey, "old-cursor", -1000); + + expect(() => resolveCursor("last", contextKey)).toThrow(ContextError); + }); +}); + +describe("writeHeader", () => { + test("writes formatted header line", () => { + const { writer, output } = createCapture(); + writeHeader(writer, 10, 15, 20); + const line = output(); + expect(line).toContain("ORG"); + expect(line).toContain("PROJECT"); + expect(line).toContain("NAME"); + expect(line).toContain("PLATFORM"); + expect(line).toEndWith("\n"); + }); + + test("respects column widths", () => { + const { writer, output } = createCapture(); + writeHeader(writer, 5, 10, 8); + const line = output(); + // "ORG" padded to 5, "PROJECT" padded to 10, "NAME" padded to 8 + expect(line).toMatch(/^ORG\s{2}\s+PROJECT\s+NAME\s+PLATFORM\n$/); + }); +}); + +describe("writeRows", () => { + test("writes one line per project", () => { + const { writer, output } = createCapture(); + const projects = [ + makeProject({ + slug: "proj-a", + name: "Project A", + platform: "javascript", + orgSlug: "org1", + }), + makeProject({ + slug: "proj-b", + name: "Project B", + platform: "python", + orgSlug: "org2", + }), + ]; + writeRows({ + stdout: writer, + projects, + orgWidth: 10, + slugWidth: 15, + nameWidth: 20, + }); + const lines = output().split("\n").filter(Boolean); + expect(lines).toHaveLength(2); + }); +}); + +describe("writeSelfHostedWarning", () => { + test("writes nothing when skippedSelfHosted is undefined", () => { + const { writer, output } = createCapture(); + writeSelfHostedWarning(writer, undefined); + expect(output()).toBe(""); + }); + + test("writes nothing when skippedSelfHosted is 0", () => { + const { writer, output } = createCapture(); + writeSelfHostedWarning(writer, 0); + expect(output()).toBe(""); + }); + + test("writes warning when skippedSelfHosted > 0", () => { + const { writer, output } = createCapture(); + writeSelfHostedWarning(writer, 3); + const text = output(); + expect(text).toContain("3 DSN(s)"); + expect(text).toContain("could not be resolved"); + }); +}); + +// Handler tests with fetch mocking + +let originalFetch: typeof globalThis.fetch; + +/** Create a mock fetch for project API calls */ +function mockProjectFetch( + projects: SentryProject[], + options: { hasMore?: boolean; nextCursor?: string } = {} +): typeof globalThis.fetch { + const { hasMore = false, nextCursor } = options; + // @ts-expect-error - partial mock + return async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // getProject (single project fetch via /projects/{org}/{slug}/) + if (url.match(/\/projects\/[^/]+\/[^/]+\//)) { + if (projects.length > 0) { + return new Response(JSON.stringify(projects[0]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + } + + // listProjects / listProjectsPaginated (via /organizations/{org}/projects/) + if (url.includes("/projects/")) { + const linkParts: string[] = [ + `<${url}>; rel="previous"; results="false"; cursor="0:0:1"`, + ]; + if (hasMore && nextCursor) { + linkParts.push( + `<${url}>; rel="next"; results="true"; cursor="${nextCursor}"` + ); + } else { + linkParts.push(`<${url}>; rel="next"; results="false"; cursor="0:0:0"`); + } + return new Response(JSON.stringify(projects), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkParts.join(", "), + }, + }); + } + + // listOrganizations + if ( + url.includes("/organizations/") && + !url.includes("/projects/") && + !url.includes("/issues/") + ) { + return new Response( + JSON.stringify([{ id: "1", slug: "test-org", name: "Test Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; +} + +const sampleProjects: SentryProject[] = [ + { + id: "1", + slug: "frontend", + name: "Frontend", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }, + { + id: "2", + slug: "backend", + name: "Backend", + platform: "python", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }, +]; + +describe("handleExplicit", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("displays single project", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "frontend", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("ORG"); + expect(text).toContain("frontend"); + }); + + test("--json outputs JSON array", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "frontend", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + }); + + test("not found shows message", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "nonexistent", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("No project"); + expect(text).toContain("nonexistent"); + expect(text).toContain("Tip:"); + }); + + test("not found with --json outputs empty array", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "nonexistent", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(0); + }); + + test("platform filter with no match shows message", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "frontend", { + limit: 30, + json: false, + platform: "ruby", + }); + + const text = output(); + expect(text).toContain("No project"); + expect(text).toContain("platform"); + }); + + test("platform filter match shows project", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "frontend", { + limit: 30, + json: false, + platform: "javascript", + }); + + const text = output(); + expect(text).toContain("frontend"); + expect(text).toContain("ORG"); + }); +}); + +describe("handleOrgAll", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("displays paginated project list", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("ORG"); + expect(text).toContain("frontend"); + expect(text).toContain("backend"); + expect(text).toContain("Showing 2 projects"); + }); + + test("--json with hasMore includes nextCursor", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: true }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const parsed = JSON.parse(output()); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBe("1735689600000:100:0"); + expect(parsed.data).toHaveLength(2); + }); + + test("--json without hasMore shows hasMore: false", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: true }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const parsed = JSON.parse(output()); + expect(parsed.hasMore).toBe(false); + expect(parsed.data).toHaveLength(2); + }); + + test("hasMore saves cursor for --cursor last", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const cached = getPaginationCursor(PAGINATION_KEY, "type:org:test-org"); + expect(cached).toBe("1735689600000:100:0"); + }); + + test("no hasMore clears cached cursor", async () => { + setPaginationCursor( + PAGINATION_KEY, + "type:org:test-org", + "old-cursor", + 300_000 + ); + + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const cached = getPaginationCursor(PAGINATION_KEY, "type:org:test-org"); + expect(cached).toBeUndefined(); + }); + + test("empty page with hasMore suggests next page", async () => { + globalThis.fetch = mockProjectFetch([], { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false, platform: "rust" }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("No matching projects on this page"); + expect(text).toContain("-c last"); + expect(text).toContain("--platform rust"); + }); + + test("empty page without hasMore shows no projects", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("No projects found"); + }); + + test("empty page without hasMore and platform filter shows platform message", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false, platform: "rust" }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("matching platform 'rust'"); + expect(text).not.toContain("No projects found in organization"); + }); + + test("hasMore shows next page hint", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("more available"); + expect(text).toContain("-c last"); + expect(text).not.toContain("--platform"); + }); + + test("hasMore with platform includes --platform in hint", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false, platform: "python" }, + contextKey: "type:org:test-org:platform:python", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("--platform python"); + expect(text).toContain("-c last"); + }); +}); + +describe("handleProjectSearch", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("finds project across orgs", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("frontend"); + }); + + test("--json outputs JSON array", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(Array.isArray(parsed)).toBe(true); + }); + + test("not found throws ContextError", async () => { + // Mock returning orgs but 404 for project lookups + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "test-org", name: "Test Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // getProject (SDK retrieveAProject) hits /projects/{org}/{slug}/ + // Return 404 to simulate project not found + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const { writer } = createCapture(); + + await expect( + handleProjectSearch(writer, "nonexistent", { + limit: 30, + json: false, + }) + ).rejects.toThrow(ContextError); + }); + + test("not found with --json outputs empty array", async () => { + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "test-org", name: "Test Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // getProject (SDK retrieveAProject) hits /projects/{org}/{slug}/ + // Return 404 to simulate project not found + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "nonexistent", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(0); + }); + + test("multiple results shows count", async () => { + globalThis.fetch = mockProjectFetch([...sampleProjects, ...sampleProjects]); + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("frontend"); + }); + + test("found but filtered by platform shows platform message, not 'not found'", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 30, + json: false, + platform: "rust", + }); + + const text = output(); + expect(text).toContain("matching platform 'rust'"); + expect(text).not.toContain("not found"); + }); + + test("respects --limit flag", async () => { + await setOrgRegion("org-a", DEFAULT_SENTRY_URL); + await setOrgRegion("org-b", DEFAULT_SENTRY_URL); + + const project: SentryProject = { + id: "1", + slug: "frontend", + name: "Frontend", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }; + + // Mock that returns 2 orgs, each with the same project slug + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.match(/\/projects\/[^/]+\/[^/]+\//)) { + return new Response(JSON.stringify(project), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 1, + json: false, + }); + + const text = output(); + // Should show truncation message since 2 matches but limit is 1 + expect(text).toContain("Showing 1 of 2 matches"); + expect(text).toContain("--limit"); + }); + + test("--limit also applies to JSON output", async () => { + await setOrgRegion("org-a", DEFAULT_SENTRY_URL); + await setOrgRegion("org-b", DEFAULT_SENTRY_URL); + + const project: SentryProject = { + id: "1", + slug: "frontend", + name: "Frontend", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }; + + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.match(/\/projects\/[^/]+\/[^/]+\//)) { + return new Response(JSON.stringify(project), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 1, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(1); + }); +}); + +// ─── displayProjectTable ──────────────────────────────────────── + +describe("displayProjectTable", () => { + test("outputs header and rows", () => { + const { writer, output } = createCapture(); + const projects = [ + makeProject({ + slug: "web", + name: "Web App", + platform: "javascript", + orgSlug: "acme", + }), + makeProject({ + slug: "api", + name: "API", + platform: "python", + orgSlug: "acme", + }), + ]; + + displayProjectTable(writer, projects); + const text = output(); + + // Header row + expect(text).toContain("ORG"); + expect(text).toContain("PROJECT"); + expect(text).toContain("NAME"); + expect(text).toContain("PLATFORM"); + + // Data rows + expect(text).toContain("web"); + expect(text).toContain("api"); + expect(text).toContain("Web App"); + expect(text).toContain("API"); + }); + + test("handles single project", () => { + const { writer, output } = createCapture(); + displayProjectTable(writer, [ + makeProject({ slug: "solo", orgSlug: "org" }), + ]); + expect(output()).toContain("solo"); + }); +}); + +// ─── fetchOrgProjects ─────────────────────────────────────────── + +describe("fetchOrgProjects", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("myorg", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns projects with orgSlug attached", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const result = await fetchOrgProjects("myorg"); + + expect(result).toHaveLength(2); + for (const p of result) { + expect(p.orgSlug).toBe("myorg"); + } + expect(result[0].slug).toBe("frontend"); + expect(result[1].slug).toBe("backend"); + }); + + test("returns empty array when org has no projects", async () => { + globalThis.fetch = mockProjectFetch([]); + const result = await fetchOrgProjects("myorg"); + expect(result).toHaveLength(0); + }); +}); + +describe("fetchOrgProjectsSafe", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("myorg", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns projects on success", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const result = await fetchOrgProjectsSafe("myorg"); + expect(result).toHaveLength(2); + }); + + test("returns empty array on non-auth error", async () => { + // @ts-expect-error - partial mock + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + }); + const result = await fetchOrgProjectsSafe("myorg"); + expect(result).toHaveLength(0); + }); + + test("propagates AuthError when not authenticated", async () => { + // Clear auth token so the API client throws AuthError before making any request + clearAuth(); + + await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); + }); +}); + +// ─── fetchAllOrgProjects ──────────────────────────────────────── + +describe("fetchAllOrgProjects", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("fetches projects from all orgs", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const result = await fetchAllOrgProjects(); + + // mockProjectFetch returns 1 org (test-org) with sampleProjects + expect(result).toHaveLength(2); + for (const p of result) { + expect(p.orgSlug).toBe("test-org"); + } + }); + + test("skips orgs with access errors", async () => { + let callCount = 0; + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // listOrganizations + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "org1", name: "Org 1" }, + { id: "2", slug: "org2", name: "Org 2" }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // projects - first org succeeds, second fails with 403 + if (url.includes("/projects/")) { + callCount += 1; + if (callCount === 1) { + return new Response(JSON.stringify(sampleProjects), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"; cursor="0:0:0"', + }, + }); + } + return new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + }); + } + + return new Response("Not found", { status: 404 }); + }; + + await setOrgRegion("org1", DEFAULT_SENTRY_URL); + await setOrgRegion("org2", DEFAULT_SENTRY_URL); + + const result = await fetchAllOrgProjects(); + // Only org1's projects should be returned + expect(result).toHaveLength(2); + }); +}); + +// ─── handleAutoDetect ─────────────────────────────────────────── + +describe("handleAutoDetect", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("shows projects from all orgs when no default org", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: false, + }); + + const text = output(); + // Should display table with projects + expect(text).toContain("ORG"); + expect(text).toContain("frontend"); + expect(text).toContain("backend"); + }); + + test("--json outputs envelope with hasMore", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore", false); + expect(parsed.data).toHaveLength(2); + }); + + test("empty results shows no projects message", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: false, + }); + + expect(output()).toContain("No projects found"); + }); + + test("respects --limit flag and indicates truncation", async () => { + const manyProjects = Array.from({ length: 5 }, (_, i) => + makeProject({ id: String(i), slug: `proj-${i}`, name: `Project ${i}` }) + ); + globalThis.fetch = mockProjectFetch(manyProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 2, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed.data).toHaveLength(2); + expect(parsed.hasMore).toBe(true); + expect(parsed.hint).toBeString(); + }); + + test("respects --platform flag", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + platform: "python", + }); + + const parsed = JSON.parse(output()); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0].platform).toBe("python"); + expect(parsed.hasMore).toBe(false); + }); + + test("shows limit message when more projects exist", async () => { + const manyProjects = Array.from({ length: 5 }, (_, i) => + makeProject({ id: String(i), slug: `proj-${i}`, name: `Project ${i}` }) + ); + globalThis.fetch = mockProjectFetch(manyProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 2, + json: false, + }); + + const text = output(); + expect(text).toContain("Showing 2 projects (more available)"); + expect(text).toContain("--limit"); + }); + + test("fast path: uses single-page fetch for single org without platform filter", async () => { + // Set default org to trigger single-org resolution + await setDefaults("test-org"); + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed.data).toHaveLength(2); + // Verify orgSlug is attached + expect(parsed.data[0].orgSlug).toBe("test-org"); + expect(parsed.hasMore).toBe(false); + }); + + test("fast path: shows truncation message when server has more results", async () => { + await setDefaults("test-org"); + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:0:0", + }); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("Showing 2 projects (more available)"); + expect(text).toContain("sentry project list test-org/"); + expect(text).not.toContain("--limit"); + }); + + test("fast path: JSON includes hasMore and hint when server has more results", async () => { + await setDefaults("test-org"); + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:0:0", + }); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed.hasMore).toBe(true); + expect(parsed.data).toHaveLength(2); + expect(parsed.hint).toContain("test-org/"); + expect(parsed.hint).toContain("--json"); + }); + + test("fast path: non-auth API errors return empty results instead of throwing", async () => { + await setDefaults("test-org"); + // Mock returns 403 for projects endpoint (stale org, no access) + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/projects/")) { + return new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed.data).toEqual([]); + expect(parsed.hasMore).toBe(false); + }); + + test("fast path: AuthError still propagates", async () => { + await setDefaults("test-org"); + // Clear auth so getAuthToken() throws AuthError before any fetch + clearAuth(); + const { writer } = createCapture(); + + await expect( + handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }) + ).rejects.toThrow(AuthError); + }); + + test("slow path: uses full fetch when platform filter is active", async () => { + // Set default org — but platform filter forces slow path + await setDefaults("test-org"); + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + platform: "python", + }); + + const parsed = JSON.parse(output()); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0].platform).toBe("python"); + expect(parsed.hasMore).toBe(false); + }); +}); diff --git a/test/e2e/multiregion.test.ts b/test/e2e/multiregion.test.ts index 7ee701a6..0e270ba7 100644 --- a/test/e2e/multiregion.test.ts +++ b/test/e2e/multiregion.test.ts @@ -163,7 +163,7 @@ describe("multi-region", () => { // First list orgs to populate region cache await ctx.run(["org", "list"]); - const result = await ctx.run(["project", "list", "acme-corp"]); + const result = await ctx.run(["project", "list", "acme-corp/"]); expect(result.exitCode).toBe(0); // Should contain US projects for acme-corp @@ -182,7 +182,7 @@ describe("multi-region", () => { // First list orgs to populate region cache await ctx.run(["org", "list"]); - const result = await ctx.run(["project", "list", "euro-gmbh"]); + const result = await ctx.run(["project", "list", "euro-gmbh/"]); expect(result.exitCode).toBe(0); // Should contain EU projects for euro-gmbh @@ -204,12 +204,14 @@ describe("multi-region", () => { const result = await ctx.run([ "project", "list", - "berlin-startup", + "berlin-startup/", "--json", ]); expect(result.exitCode).toBe(0); - const data = JSON.parse(result.stdout); + // JSON output in paginated mode wraps data in { data, hasMore } + const parsed = JSON.parse(result.stdout); + const data = Array.isArray(parsed) ? parsed : parsed.data; expect(Array.isArray(data)).toBe(true); const slugs = data.map((p: { slug: string }) => p.slug); diff --git a/test/e2e/project.test.ts b/test/e2e/project.test.ts index 15506698..8d653dff 100644 --- a/test/e2e/project.test.ts +++ b/test/e2e/project.test.ts @@ -97,11 +97,11 @@ describe("sentry project list", () => { async () => { await ctx.setAuthToken(TEST_TOKEN); - // Use positional argument for organization + // Use org/ syntax for org-scoped listing const result = await ctx.run([ "project", "list", - TEST_ORG, + `${TEST_ORG}/`, "--limit", "5", ]); @@ -116,18 +116,20 @@ describe("sentry project list", () => { async () => { await ctx.setAuthToken(TEST_TOKEN); - // Use positional argument for organization + // Use org/ syntax for org-scoped listing const result = await ctx.run([ "project", "list", - TEST_ORG, + `${TEST_ORG}/`, "--json", "--limit", "5", ]); expect(result.exitCode).toBe(0); - const data = JSON.parse(result.stdout); + // JSON output in paginated mode wraps data in { data, hasMore } + const parsed = JSON.parse(result.stdout); + const data = Array.isArray(parsed) ? parsed : parsed.data; expect(Array.isArray(data)).toBe(true); }, { timeout: 15_000 } diff --git a/test/lib/api-client.property.test.ts b/test/lib/api-client.property.test.ts new file mode 100644 index 00000000..47e56f0e --- /dev/null +++ b/test/lib/api-client.property.test.ts @@ -0,0 +1,185 @@ +/** + * Property-Based Tests for API Client Pagination Helpers + * + * Tests parseLinkHeader — the pure function that extracts pagination cursors + * from Sentry's RFC 5988 Link response headers. + */ + +import { describe, expect, test } from "bun:test"; +import { + constantFrom, + assert as fcAssert, + nat, + property, + string, + tuple, +} from "fast-check"; +import { parseLinkHeader } from "../../src/lib/api-client.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries + +/** Generate a Sentry-style cursor: `timestamp:offset:is_prev` */ +const cursorArb = tuple( + nat(2_000_000_000_000), + nat(100), + constantFrom(0, 1) +).map(([ts, offset, isPrev]) => `${ts}:${offset}:${isPrev}`); + +/** Generate a valid "next" link part with results="true" and a cursor */ +const nextLinkWithResultsArb = cursorArb.map( + (cursor) => + `; rel="next"; results="true"; cursor="${cursor}"` +); + +/** Generate a "next" link part with results="false" */ +const nextLinkNoResultsArb = cursorArb.map( + (cursor) => + `; rel="next"; results="false"; cursor="${cursor}"` +); + +/** Generate a "previous" link part (should be ignored by parseLinkHeader) */ +const prevLinkArb = cursorArb.map( + (cursor) => + `; rel="previous"; results="true"; cursor="${cursor}"` +); + +/** Generate a rel value that is not "next" */ +const nonNextRelArb = constantFrom("previous", "first", "last", "self"); + +describe("property: parseLinkHeader", () => { + test("null or empty header returns no cursor", () => { + const result1 = parseLinkHeader(null); + expect(result1).toEqual({}); + + const result2 = parseLinkHeader(""); + expect(result2).toEqual({}); + }); + + test("valid next link with results=true returns cursor", () => { + fcAssert( + property(nextLinkWithResultsArb, (header) => { + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeDefined(); + expect(result.nextCursor).toMatch(/^\d+:\d+:\d+$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("next link with results=false returns no cursor", () => { + fcAssert( + property(nextLinkNoResultsArb, (header) => { + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("previous-only link returns no cursor (ignores non-next)", () => { + fcAssert( + property(prevLinkArb, (header) => { + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("combined prev + next link extracts cursor from next part", () => { + fcAssert( + property(tuple(prevLinkArb, nextLinkWithResultsArb), ([prev, next]) => { + // Sentry sends both prev and next separated by comma + const header = `${prev}, ${next}`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeDefined(); + expect(result.nextCursor).toMatch(/^\d+:\d+:\d+$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("cursor value is preserved exactly", () => { + fcAssert( + property(cursorArb, (cursor) => { + const header = `; rel="next"; results="true"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBe(cursor); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("missing cursor attribute returns no cursor", () => { + const header = + '; rel="next"; results="true"'; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }); + + test("missing results attribute returns no cursor", () => { + fcAssert( + property(cursorArb, (cursor) => { + const header = `; rel="next"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-next rel with results=true returns no cursor", () => { + fcAssert( + property(tuple(nonNextRelArb, cursorArb), ([rel, cursor]) => { + const header = `; rel="${rel}"; results="true"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("multiple parts: only next with results=true is extracted", () => { + fcAssert( + property(tuple(cursorArb, cursorArb), ([prevCursor, nextCursor]) => { + const header = [ + `; rel="previous"; results="false"; cursor="${prevCursor}"`, + `; rel="next"; results="true"; cursor="${nextCursor}"`, + ].join(", "); + const result = parseLinkHeader(header); + expect(result.nextCursor).toBe(nextCursor); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("random strings without expected attributes return no cursor", () => { + fcAssert( + property(string({ minLength: 0, maxLength: 200 }), (header) => { + // Any random string should not crash and should return a valid result + const result = parseLinkHeader(header); + if (result.nextCursor !== undefined) { + expect(typeof result.nextCursor).toBe("string"); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("real Sentry response header with both links", () => { + const header = + '; rel="previous"; results="false"; cursor="1735689600000:0:1", ' + + '; rel="next"; results="true"; cursor="1735689600000:100:0"'; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBe("1735689600000:100:0"); + }); + + test("real Sentry last-page response header", () => { + const header = + '; rel="previous"; results="true"; cursor="1735689600000:0:1", ' + + '; rel="next"; results="false"; cursor="1735689600000:200:0"'; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBeUndefined(); + }); +}); diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 15ec7d57..e16920b2 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -592,31 +592,29 @@ describe("findProjectsBySlug", () => { ); } - // Projects for acme org - has matching project - if (url.includes("/organizations/acme/projects/")) { + // getProject for acme/frontend - found + if (url.includes("/projects/acme/frontend/")) { return new Response( - JSON.stringify([ - { id: "101", slug: "frontend", name: "Frontend" }, - { id: "102", slug: "backend", name: "Backend" }, - ]), + JSON.stringify({ id: "101", slug: "frontend", name: "Frontend" }), { status: 200, headers: { "Content-Type": "application/json" } } ); } - // Projects for beta org - also has matching project - if (url.includes("/organizations/beta/projects/")) { + // getProject for beta/frontend - found + if (url.includes("/projects/beta/frontend/")) { return new Response( - JSON.stringify([ - { id: "201", slug: "frontend", name: "Beta Frontend" }, - { id: "202", slug: "api", name: "API" }, - ]), + JSON.stringify({ + id: "201", + slug: "frontend", + name: "Beta Frontend", + }), { status: 200, headers: { "Content-Type": "application/json" } } ); } - // Default response - return new Response(JSON.stringify([]), { - status: 200, + // Default - not found + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, headers: { "Content-Type": "application/json" }, }); }; @@ -655,16 +653,9 @@ describe("findProjectsBySlug", () => { ); } - // Projects - no match - if (url.includes("/organizations/acme/projects/")) { - return new Response( - JSON.stringify([{ id: "101", slug: "backend", name: "Backend" }]), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - } - - return new Response(JSON.stringify([]), { - status: 200, + // getProject - not found (404) + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, headers: { "Content-Type": "application/json" }, }); }; @@ -702,24 +693,24 @@ describe("findProjectsBySlug", () => { ); } - // Projects for acme - success - if (url.includes("/organizations/acme/projects/")) { + // getProject for acme/frontend - success + if (url.includes("/projects/acme/frontend/")) { return new Response( - JSON.stringify([{ id: "101", slug: "frontend", name: "Frontend" }]), + JSON.stringify({ id: "101", slug: "frontend", name: "Frontend" }), { status: 200, headers: { "Content-Type": "application/json" } } ); } - // Projects for restricted org - 403 forbidden - if (url.includes("/organizations/restricted/projects/")) { + // getProject for restricted/frontend - 403 forbidden + if (url.includes("/projects/restricted/frontend/")) { return new Response(JSON.stringify({ detail: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" }, }); } - return new Response(JSON.stringify([]), { - status: 200, + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, headers: { "Content-Type": "application/json" }, }); }; diff --git a/test/lib/db/model-based.test.ts b/test/lib/db/model-based.test.ts index 9a490958..6b62756b 100644 --- a/test/lib/db/model-based.test.ts +++ b/test/lib/db/model-based.test.ts @@ -37,6 +37,10 @@ import { isAuthenticated, setAuthToken, } from "../../../src/lib/db/auth.js"; +import { + getPaginationCursor, + setPaginationCursor, +} from "../../../src/lib/db/pagination.js"; import { clearProjectAliases, getProjectAliases, @@ -676,6 +680,38 @@ describe("model-based: database layer", () => { ); }); + test("clearAuth also clears pagination cursors (key invariant)", () => { + fcAssert( + asyncProperty(tuple(slugArb, slugArb), async ([commandKey, context]) => { + const cleanup = createIsolatedDbContext(); + try { + // Set up auth and a pagination cursor + setAuthToken("test-token"); + setPaginationCursor( + commandKey, + context, + "1735689600000:100:0", + 300_000 + ); + + // Verify cursor was stored + const before = getPaginationCursor(commandKey, context); + expect(before).toBe("1735689600000:100:0"); + + // Clear auth + clearAuth(); + + // Verify pagination cursor was also cleared (this is the invariant!) + const after = getPaginationCursor(commandKey, context); + expect(after).toBeUndefined(); + } finally { + cleanup(); + } + }), + { numRuns: 50 } + ); + }); + test("alias lookup is case-insensitive", () => { fcAssert( asyncProperty( diff --git a/test/lib/db/pagination.model-based.test.ts b/test/lib/db/pagination.model-based.test.ts new file mode 100644 index 00000000..e43519b1 --- /dev/null +++ b/test/lib/db/pagination.model-based.test.ts @@ -0,0 +1,348 @@ +/** + * Model-Based Tests for Pagination Cursor Storage + * + * Uses fast-check to generate random sequences of get/set/clear operations + * and verifies behavior against a simplified model, including TTL expiry + * and composite primary key semantics. + */ + +// biome-ignore-all lint/suspicious/noMisplacedAssertion: Model-based testing uses expect() inside command classes, not directly in test() functions. This is the standard fast-check pattern for stateful testing. + +import { describe, expect, test } from "bun:test"; +import { + type AsyncCommand, + asyncModelRun, + asyncProperty, + commands, + constantFrom, + assert as fcAssert, + integer, + property, + tuple, +} from "fast-check"; +import { + clearPaginationCursor, + getPaginationCursor, + setPaginationCursor, +} from "../../../src/lib/db/pagination.js"; +import { + createIsolatedDbContext, + DEFAULT_NUM_RUNS, +} from "../../model-based/helpers.js"; + +/** + * Model representing expected pagination cursor state. + * Maps composite key `${commandKey}::${context}` to cursor info. + */ +type PaginationModel = { + cursors: Map; +}; + +/** Real system (we use module functions directly) */ +type RealDb = Record; + +/** Composite key for the model */ +function compositeKey(commandKey: string, context: string): string { + return `${commandKey}::${context}`; +} + +/** Create initial empty model */ +function createEmptyModel(): PaginationModel { + return { cursors: new Map() }; +} + +// Command classes + +class SetPaginationCursorCommand + implements AsyncCommand +{ + readonly commandKey: string; + readonly context: string; + readonly cursor: string; + readonly ttlMs: number; + + constructor( + commandKey: string, + context: string, + cursor: string, + ttlMs: number + ) { + this.commandKey = commandKey; + this.context = context; + this.cursor = cursor; + this.ttlMs = ttlMs; + } + + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + const now = Date.now(); + setPaginationCursor(this.commandKey, this.context, this.cursor, this.ttlMs); + + const key = compositeKey(this.commandKey, this.context); + model.cursors.set(key, { + cursor: this.cursor, + expiresAt: now + this.ttlMs, + }); + } + + toString(): string { + return `setPaginationCursor("${this.commandKey}", "${this.context}", "${this.cursor}", ${this.ttlMs})`; + } +} + +class GetPaginationCursorCommand + implements AsyncCommand +{ + readonly commandKey: string; + readonly context: string; + + constructor(commandKey: string, context: string) { + this.commandKey = commandKey; + this.context = context; + } + + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + const realCursor = getPaginationCursor(this.commandKey, this.context); + const key = compositeKey(this.commandKey, this.context); + const entry = model.cursors.get(key); + + if (!entry) { + expect(realCursor).toBeUndefined(); + return; + } + + const now = Date.now(); + if (entry.expiresAt <= now) { + // Expired — real system should return undefined and delete the row + expect(realCursor).toBeUndefined(); + model.cursors.delete(key); + } else { + expect(realCursor).toBe(entry.cursor); + } + } + + toString(): string { + return `getPaginationCursor("${this.commandKey}", "${this.context}")`; + } +} + +class ClearPaginationCursorCommand + implements AsyncCommand +{ + readonly commandKey: string; + readonly context: string; + + constructor(commandKey: string, context: string) { + this.commandKey = commandKey; + this.context = context; + } + + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + clearPaginationCursor(this.commandKey, this.context); + const key = compositeKey(this.commandKey, this.context); + model.cursors.delete(key); + } + + toString(): string { + return `clearPaginationCursor("${this.commandKey}", "${this.context}")`; + } +} + +// Arbitraries + +const commandKeyArb = constantFrom("project-list", "issue-list", "log-list"); +const contextArb = constantFrom( + "org:sentry", + "org:acme", + "org:getsentry", + "auto", + "org:sentry|platform:python" +); +const cursorArb = constantFrom( + "1735689600000:0:0", + "1735689600000:100:0", + "1735689600000:200:0", + "9999999999999:50:1" +); + +/** TTL that won't expire during test (5 minutes) */ +const longTtlArb = integer({ min: 60_000, max: 300_000 }); + +// Command arbitraries + +const setCmdArb = tuple(commandKeyArb, contextArb, cursorArb, longTtlArb).map( + ([ck, ctx, cur, ttl]) => new SetPaginationCursorCommand(ck, ctx, cur, ttl) +); + +const getCmdArb = tuple(commandKeyArb, contextArb).map( + ([ck, ctx]) => new GetPaginationCursorCommand(ck, ctx) +); + +const clearCmdArb = tuple(commandKeyArb, contextArb).map( + ([ck, ctx]) => new ClearPaginationCursorCommand(ck, ctx) +); + +const allCommands = [setCmdArb, getCmdArb, clearCmdArb]; + +// Tests + +describe("model-based: pagination cursor storage", () => { + test("random sequences of pagination operations maintain consistency", () => { + fcAssert( + asyncProperty(commands(allCommands, { size: "+1" }), async (cmds) => { + const cleanup = createIsolatedDbContext(); + try { + const setup = () => ({ + model: createEmptyModel(), + real: {} as RealDb, + }); + await asyncModelRun(setup, cmds); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS, verbose: false } + ); + }); + + test("composite key: different contexts are independent", () => { + fcAssert( + property( + tuple(commandKeyArb, cursorArb, cursorArb), + ([commandKey, cursor1, cursor2]) => { + const cleanup = createIsolatedDbContext(); + try { + const ctx1 = "org:sentry"; + const ctx2 = "org:acme"; + + // Set cursors for two different contexts + setPaginationCursor(commandKey, ctx1, cursor1, 300_000); + setPaginationCursor(commandKey, ctx2, cursor2, 300_000); + + // Each returns its own cursor + expect(getPaginationCursor(commandKey, ctx1)).toBe(cursor1); + expect(getPaginationCursor(commandKey, ctx2)).toBe(cursor2); + + // Clear one, the other remains + clearPaginationCursor(commandKey, ctx1); + expect(getPaginationCursor(commandKey, ctx1)).toBeUndefined(); + expect(getPaginationCursor(commandKey, ctx2)).toBe(cursor2); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("composite key: different command keys are independent", () => { + fcAssert( + property( + tuple(contextArb, cursorArb, cursorArb), + ([context, cursor1, cursor2]) => { + const cleanup = createIsolatedDbContext(); + try { + const cmd1 = "project-list"; + const cmd2 = "issue-list"; + + setPaginationCursor(cmd1, context, cursor1, 300_000); + setPaginationCursor(cmd2, context, cursor2, 300_000); + + expect(getPaginationCursor(cmd1, context)).toBe(cursor1); + expect(getPaginationCursor(cmd2, context)).toBe(cursor2); + + clearPaginationCursor(cmd1, context); + expect(getPaginationCursor(cmd1, context)).toBeUndefined(); + expect(getPaginationCursor(cmd2, context)).toBe(cursor2); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("expired cursors return undefined and are deleted", () => { + fcAssert( + property( + tuple(commandKeyArb, contextArb, cursorArb), + ([commandKey, context, cursor]) => { + const cleanup = createIsolatedDbContext(); + try { + // Set with immediately-expired TTL + setPaginationCursor(commandKey, context, cursor, -1000); + + // Should return undefined + const result = getPaginationCursor(commandKey, context); + expect(result).toBeUndefined(); + + // Second get should also return undefined (row was deleted on first get) + const result2 = getPaginationCursor(commandKey, context); + expect(result2).toBeUndefined(); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("upsert: setting same key twice updates the cursor", () => { + fcAssert( + property( + tuple(commandKeyArb, contextArb, cursorArb, cursorArb), + ([commandKey, context, cursor1, cursor2]) => { + const cleanup = createIsolatedDbContext(); + try { + setPaginationCursor(commandKey, context, cursor1, 300_000); + expect(getPaginationCursor(commandKey, context)).toBe(cursor1); + + setPaginationCursor(commandKey, context, cursor2, 300_000); + expect(getPaginationCursor(commandKey, context)).toBe(cursor2); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("get on empty table returns undefined", () => { + fcAssert( + property(tuple(commandKeyArb, contextArb), ([commandKey, context]) => { + const cleanup = createIsolatedDbContext(); + try { + expect(getPaginationCursor(commandKey, context)).toBeUndefined(); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("clear on non-existent key is a no-op", () => { + fcAssert( + property(tuple(commandKeyArb, contextArb), ([commandKey, context]) => { + const cleanup = createIsolatedDbContext(); + try { + // Should not throw + clearPaginationCursor(commandKey, context); + expect(getPaginationCursor(commandKey, context)).toBeUndefined(); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/db/schema.test.ts b/test/lib/db/schema.test.ts index 6b322edd..1ea1612a 100644 --- a/test/lib/db/schema.test.ts +++ b/test/lib/db/schema.test.ts @@ -27,9 +27,8 @@ function createDatabaseWithMissingTables( ): void { const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { - if (!missingTables.includes(tableName)) { - statements.push(EXPECTED_TABLES[tableName] as string); - } + if (missingTables.includes(tableName)) continue; + statements.push(EXPECTED_TABLES[tableName] as string); } db.exec(statements.join(";\n")); db.query("INSERT INTO schema_version (version) VALUES (?)").run( @@ -231,6 +230,7 @@ describe("EXPECTED_TABLES", () => { "user_info", "instance_info", "project_root_cache", + "pagination_cursors", ]; for (const table of expectedTableNames) {