diff --git a/README.md b/README.md index 8b71aca..fee1c09 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ The client exposes the following resource modules: | `client.webhooks` | Outgoing webhook endpoints | | `client.apiKeys` | API key management | | `client.dependencies` | Service dependency tracking | +| `client.services` | Status Data catalog (third-party service status, incidents, uptime) | | `client.deployLock` | Deploy lock for safe deployments | | `client.statusPages` | Public status page management | | `client.status` | Dashboard overview | diff --git a/docs/openapi/monitoring-api.json b/docs/openapi/monitoring-api.json index 22b4050..444dc1b 100644 --- a/docs/openapi/monitoring-api.json +++ b/docs/openapi/monitoring-api.json @@ -13520,6 +13520,15 @@ "type": "boolean" } }, + { + "name": "search", + "in": "query", + "description": "Case-insensitive substring match on service name or slug", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "cursor", "in": "query", diff --git a/src/client.ts b/src/client.ts index 6e82f7d..f05d656 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,6 +12,7 @@ import {ResourceGroups} from './resources/resource-groups.js' import {Webhooks} from './resources/webhooks.js' import {ApiKeys} from './resources/api-keys.js' import {Dependencies} from './resources/dependencies.js' +import {Services} from './resources/services.js' import {DeployLock} from './resources/deploy-lock.js' import {Status} from './resources/status.js' import {StatusPages} from './resources/status-pages.js' @@ -48,6 +49,7 @@ export class Devhelm { readonly webhooks: Webhooks readonly apiKeys: ApiKeys readonly dependencies: Dependencies + readonly services: Services readonly deployLock: DeployLock readonly status: Status readonly statusPages: StatusPages @@ -67,6 +69,7 @@ export class Devhelm { this.webhooks = new Webhooks(client) this.apiKeys = new ApiKeys(client) this.dependencies = new Dependencies(client) + this.services = new Services(client) this.deployLock = new DeployLock(client) this.status = new Status(client) this.statusPages = new StatusPages(client) diff --git a/src/generated/api.ts b/src/generated/api.ts index e2387df..5ab88d9 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -18741,6 +18741,8 @@ export interface operations { status?: string; /** @description Filter by published status for pSEO pages */ published?: boolean; + /** @description Case-insensitive substring match on service name or slug */ + search?: string; /** @description Opaque cursor from a previous response */ cursor?: string; /** @description Page size (1–100, default 20) */ diff --git a/src/index.ts b/src/index.ts index 66f92e2..f1fca22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,25 @@ export type { AssertionTestResultDto, MonitorTestResultDto, MaintenanceWindowDto, + ServiceCatalogDto, + ServiceDetailDto, + ServiceLiveStatusDto, + ServiceStatusDto, + CategoryDto, + GlobalStatusSummaryDto, + ServiceComponentDto, + ComponentStatusDto, + ComponentsSummaryDto, + ComponentUptimeSummaryDto, + ComponentUptimeDayDto, + BatchComponentUptimeDto, + ServiceDayDetailDto, + ServiceIncidentDto, + ServiceIncidentDetailDto, + ServiceIncidentUpdateDto, + ServiceUptimeResponse, + UptimeBucketDto, + ScheduledMaintenanceDto, IncidentTimelineDto, CheckTraceDto, PolicySnapshotDto, @@ -63,6 +82,8 @@ export type { AcquireDeployLockRequest, CreateMaintenanceWindowRequest, UpdateMaintenanceWindowRequest, + ServiceSubscribeRequest, + UpdateAlertSensitivityRequest, StatusPageDto, StatusPageComponentDto, StatusPageComponentGroupDto, @@ -109,6 +130,8 @@ export {ResourceGroups} from './resources/resource-groups.js' export {Webhooks} from './resources/webhooks.js' export {ApiKeys} from './resources/api-keys.js' export {Dependencies} from './resources/dependencies.js' +export {Services} from './resources/services.js' +export type {ServiceListFilters, ServiceIncidentFilters, UptimeRangeOptions} from './resources/services.js' export {DeployLock} from './resources/deploy-lock.js' export {Status} from './resources/status.js' export {StatusPages} from './resources/status-pages.js' diff --git a/src/resources/dependencies.ts b/src/resources/dependencies.ts index 08cc7d6..3a77412 100644 --- a/src/resources/dependencies.ts +++ b/src/resources/dependencies.ts @@ -1,7 +1,12 @@ import type {ApiClient} from '../http.js' -import type {ServiceSubscriptionDto, Page} from '../types.js' -import {ServiceSubscriptionDtoSchema} from '../schemas.js' +import type {ServiceSubscriptionDto, ServiceSubscribeRequest, Page} from '../types.js' +import { + ServiceSubscriptionDtoSchema, + ServiceSubscribeRequestSchema, + UpdateAlertSensitivityRequestSchema, +} from '../schemas.js' import {fetchAllPages, fetchPage, fetchSingle, fetchVoid} from '../http.js' +import {validateRequest} from '../validation.js' export class Dependencies { constructor(private readonly client: ApiClient) {} @@ -24,12 +29,37 @@ export class Dependencies { /** * Track (subscribe to) a service from the catalog by its slug. * - * The endpoint takes no request body per the OpenAPI spec — the slug is - * the entire payload. If a body is added upstream, wire `validateRequest` - * here against the new schema before sending. + * Optionally scope the subscription to a single component (`componentId`) + * and/or set the alert sensitivity. When neither option is provided the + * request is sent without a body, matching the server's defaults + * (whole-service subscription, default sensitivity). */ - async track(slug: string): Promise { - return fetchSingle(this.client, 'POST', `/api/v1/service-subscriptions/${slug}`, ServiceSubscriptionDtoSchema) + async track(slug: string, options?: ServiceSubscribeRequest): Promise { + const hasBody = options !== undefined && (options.componentId !== undefined || options.alertSensitivity !== undefined) + if (hasBody) validateRequest(ServiceSubscribeRequestSchema, options, 'dependencies.track') + return fetchSingle( + this.client, + 'POST', + `/api/v1/service-subscriptions/${slug}`, + ServiceSubscriptionDtoSchema, + hasBody ? options : undefined, + ) + } + + /** Change how sensitively a subscription alerts (e.g. `ALL`, `MAJOR_ONLY`). */ + async updateAlertSensitivity( + subscriptionId: string | number, + alertSensitivity: string, + ): Promise { + const body = {alertSensitivity} + validateRequest(UpdateAlertSensitivityRequestSchema, body, 'dependencies.updateAlertSensitivity') + return fetchSingle( + this.client, + 'PATCH', + `/api/v1/service-subscriptions/${subscriptionId}/alert-sensitivity`, + ServiceSubscriptionDtoSchema, + body, + ) } /** Untrack (unsubscribe from) a service subscription. */ diff --git a/src/resources/services.ts b/src/resources/services.ts new file mode 100644 index 0000000..045e30f --- /dev/null +++ b/src/resources/services.ts @@ -0,0 +1,231 @@ +import type {ApiClient} from '../http.js' +import type { + ServiceCatalogDto, + ServiceDetailDto, + ServiceLiveStatusDto, + CategoryDto, + GlobalStatusSummaryDto, + ServiceComponentDto, + ComponentUptimeDayDto, + BatchComponentUptimeDto, + ServiceDayDetailDto, + ServiceIncidentDto, + ServiceIncidentDetailDto, + ServiceUptimeResponse, + ScheduledMaintenanceDto, + Page, + CursorPage, +} from '../types.js' +import { + ServiceCatalogDtoSchema, + ServiceDetailDtoSchema, + ServiceLiveStatusDtoSchema, + CategoryDtoSchema, + GlobalStatusSummaryDtoSchema, + ServiceComponentDtoSchema, + ComponentUptimeDayDtoSchema, + BatchComponentUptimeDtoSchema, + ServiceDayDetailDtoSchema, + ServiceIncidentDtoSchema, + ServiceIncidentDetailDtoSchema, + ServiceUptimeResponseSchema, + ScheduledMaintenanceDtoSchema, +} from '../schemas.js' +import {apiGet, fetchSingle} from '../http.js' +import {parsePage, parseCursorPage} from '../validation.js' + +const BASE = '/api/v1/services' + +/** Filters for listing catalog services. */ +export interface ServiceListFilters { + /** Filter by category slug (see `client.services.categories()`). */ + category?: string + /** Filter by current overall status (e.g. `OPERATIONAL`, `MAJOR_OUTAGE`). */ + status?: string + /** Free-text search over service name and slug. */ + search?: string + /** Opaque cursor from a previous page's `nextCursor`. */ + cursor?: string + /** Page size. */ + limit?: number +} + +/** Filters for listing service incidents (per-service or global). */ +export interface ServiceIncidentFilters { + /** When set, lists incidents for this service only; otherwise lists across all services. */ + slugOrId?: string + /** Filter by incident status (e.g. `investigating`, `resolved`). */ + status?: string + /** Only incidents that started at or after this ISO 8601 timestamp. */ + from?: string + /** Filter by service category (global mode only). */ + category?: string + page?: number + size?: number +} + +/** Time-range options for component uptime queries. */ +export interface UptimeRangeOptions { + /** Named period (e.g. `30d`, `90d`). Mutually exclusive with from/to. */ + period?: string + /** Range start, ISO 8601. */ + from?: string + /** Range end, ISO 8601. */ + to?: string +} + +/** + * Read-only Status Data catalog — third-party services DevHelm polls + * (their live status, components, incidents, uptime, and maintenances). + * + * To subscribe your org to a service for alerting, see + * `client.dependencies.track()`. + */ +export class Services { + constructor(private readonly client: ApiClient) {} + + /** List catalog services (cursor-paginated). */ + async list(options: ServiceListFilters = {}): Promise> { + const query: Record = {} + if (options.category !== undefined) query['category'] = options.category + if (options.status !== undefined) query['status'] = options.status + if (options.search !== undefined) query['search'] = options.search + if (options.cursor !== undefined) query['cursor'] = options.cursor + if (options.limit !== undefined) query['limit'] = options.limit + + const raw = await apiGet(this.client, BASE, query) + const validated = parseCursorPage(ServiceCatalogDtoSchema, raw, BASE) + return { + data: validated.data, + nextCursor: validated.nextCursor ?? null, + hasMore: validated.hasMore, + } + } + + /** Get a single service by slug or UUID. Set `summary` to omit heavy detail sections. */ + async get(slugOrId: string, options: {summary?: boolean} = {}): Promise { + const query: Record = {} + if (options.summary !== undefined) query['summary'] = options.summary + return fetchSingle(this.client, 'GET', `${BASE}/${slugOrId}`, ServiceDetailDtoSchema, undefined, query) + } + + /** Current live status of a service (overall + per-component). */ + async liveStatus(slugOrId: string): Promise { + return fetchSingle(this.client, 'GET', `${BASE}/${slugOrId}/live-status`, ServiceLiveStatusDtoSchema) + } + + /** List catalog categories with per-category service counts. */ + async categories(): Promise { + const path = '/api/v1/categories' + const raw = await apiGet(this.client, path) + return parsePage(CategoryDtoSchema, raw, path).data + } + + /** Global status summary across the whole catalog. */ + async summary(): Promise { + return fetchSingle(this.client, 'GET', `${BASE}/summary`, GlobalStatusSummaryDtoSchema) + } + + /** List components of a service, optionally scoped to a component group. */ + async components(slugOrId: string, options: {groupId?: string} = {}): Promise { + const path = `${BASE}/${slugOrId}/components` + const query: Record = {} + if (options.groupId !== undefined) query['groupId'] = options.groupId + const raw = await apiGet(this.client, path, query) + return parsePage(ServiceComponentDtoSchema, raw, path).data + } + + /** Daily uptime history for a single component. */ + async componentUptime( + slugOrId: string, + componentId: string, + options: UptimeRangeOptions = {}, + ): Promise { + const path = `${BASE}/${slugOrId}/components/${componentId}/uptime` + const raw = await apiGet(this.client, path, buildRangeQuery(options)) + return parsePage(ComponentUptimeDayDtoSchema, raw, path).data + } + + /** Daily uptime history for every component of a service, keyed by component id. */ + async batchComponentUptime(slugOrId: string, options: UptimeRangeOptions = {}): Promise { + return fetchSingle( + this.client, + 'GET', + `${BASE}/${slugOrId}/components/uptime`, + BatchComponentUptimeDtoSchema, + undefined, + buildRangeQuery(options), + ) + } + + /** Detailed breakdown of a single day (`date` as `YYYY-MM-DD`). */ + async day(slugOrId: string, date: string): Promise { + return fetchSingle(this.client, 'GET', `${BASE}/${slugOrId}/days/${date}`, ServiceDayDetailDtoSchema) + } + + /** + * List service incidents (paginated). + * + * With `slugOrId` set, lists incidents for that service; without it, + * lists incidents across the whole catalog (optionally filtered by + * `category`, which only applies in global mode). + */ + async incidents(options: ServiceIncidentFilters = {}): Promise> { + const path = options.slugOrId !== undefined ? `${BASE}/${options.slugOrId}/incidents` : `${BASE}/incidents` + const query: Record = { + page: options.page ?? 0, + size: options.size ?? 20, + } + if (options.status !== undefined) query['status'] = options.status + if (options.from !== undefined) query['from'] = options.from + if (options.slugOrId === undefined && options.category !== undefined) query['category'] = options.category + + const raw = await apiGet(this.client, path, query) + const validated = parsePage(ServiceIncidentDtoSchema, raw, path) + return { + data: validated.data, + hasNext: validated.hasNext, + hasPrev: validated.hasPrev, + totalElements: validated.totalElements ?? null, + totalPages: validated.totalPages ?? null, + } + } + + /** Get a single service incident with its update timeline. */ + async incident(slugOrId: string, incidentId: string): Promise { + return fetchSingle( + this.client, + 'GET', + `${BASE}/${slugOrId}/incidents/${incidentId}`, + ServiceIncidentDetailDtoSchema, + ) + } + + /** Service-level uptime over a period, bucketed by granularity. */ + async uptime( + slugOrId: string, + options: {period?: string; granularity?: string} = {}, + ): Promise { + const query: Record = {} + if (options.period !== undefined) query['period'] = options.period + if (options.granularity !== undefined) query['granularity'] = options.granularity + return fetchSingle(this.client, 'GET', `${BASE}/${slugOrId}/uptime`, ServiceUptimeResponseSchema, undefined, query) + } + + /** List scheduled maintenances for a service, optionally filtered by status(es). */ + async maintenances(slugOrId: string, options: {status?: string | string[]} = {}): Promise { + const path = `${BASE}/${slugOrId}/maintenances` + const query: Record = {} + if (options.status !== undefined) query['status'] = options.status + const raw = await apiGet(this.client, path, query) + return parsePage(ScheduledMaintenanceDtoSchema, raw, path).data + } +} + +function buildRangeQuery(options: UptimeRangeOptions): Record { + const query: Record = {} + if (options.period !== undefined) query['period'] = options.period + if (options.from !== undefined) query['from'] = options.from + if (options.to !== undefined) query['to'] = options.to + return query +} diff --git a/src/schemas.ts b/src/schemas.ts index 360c6a1..0f7d9ce 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -41,6 +41,28 @@ export const PolicySnapshotDtoSchema = schemas.PolicySnapshotDto export const RuleEvaluationDtoSchema = schemas.RuleEvaluationDto export const IncidentStateTransitionDtoSchema = schemas.IncidentStateTransitionDto +// ── Status Data catalog DTO schemas ───────────────────────────────── + +export const ServiceCatalogDtoSchema = schemas.ServiceCatalogDto +export const ServiceDetailDtoSchema = schemas.ServiceDetailDto +export const ServiceLiveStatusDtoSchema = schemas.ServiceLiveStatusDto +export const ServiceStatusDtoSchema = schemas.ServiceStatusDto +export const CategoryDtoSchema = schemas.CategoryDto +export const GlobalStatusSummaryDtoSchema = schemas.GlobalStatusSummaryDto +export const ServiceComponentDtoSchema = schemas.ServiceComponentDto +export const ComponentStatusDtoSchema = schemas.ComponentStatusDto +export const ComponentsSummaryDtoSchema = schemas.ComponentsSummaryDto +export const ComponentUptimeSummaryDtoSchema = schemas.ComponentUptimeSummaryDto +export const ComponentUptimeDayDtoSchema = schemas.ComponentUptimeDayDto +export const BatchComponentUptimeDtoSchema = schemas.BatchComponentUptimeDto +export const ServiceDayDetailDtoSchema = schemas.ServiceDayDetailDto +export const ServiceIncidentDtoSchema = schemas.ServiceIncidentDto +export const ServiceIncidentDetailDtoSchema = schemas.ServiceIncidentDetailDto +export const ServiceIncidentUpdateDtoSchema = schemas.ServiceIncidentUpdateDto +export const ServiceUptimeResponseSchema = schemas.ServiceUptimeResponse +export const UptimeBucketDtoSchema = schemas.UptimeBucketDto +export const ScheduledMaintenanceDtoSchema = schemas.ScheduledMaintenanceDto + // ── Status Page DTO schemas ───────────────────────────────────────── export const StatusPageDtoSchema = schemas.StatusPageDto @@ -78,6 +100,8 @@ export const CreateApiKeyRequestSchema = schemas.CreateApiKeyRequest export const AcquireDeployLockRequestSchema = schemas.AcquireDeployLockRequest export const CreateMaintenanceWindowRequestSchema = schemas.CreateMaintenanceWindowRequest export const UpdateMaintenanceWindowRequestSchema = schemas.UpdateMaintenanceWindowRequest +export const ServiceSubscribeRequestSchema = schemas.ServiceSubscribeRequest +export const UpdateAlertSensitivityRequestSchema = schemas.UpdateAlertSensitivityRequest // ── Status Page Request schemas ───────────────────────────────────── @@ -137,6 +161,13 @@ export const SingleValueResponseStatusPageCustomDomainDtoSchema = schemas.Single export const SingleValueResponseTestChannelResultSchema = schemas.SingleValueResponseTestChannelResult export const SingleValueResponseWebhookTestResultSchema = schemas.SingleValueResponseWebhookTestResult export const SingleValueResponseResourceGroupMemberDtoSchema = schemas.SingleValueResponseResourceGroupMemberDto +export const SingleValueResponseServiceDetailDtoSchema = schemas.SingleValueResponseServiceDetailDto +export const SingleValueResponseServiceLiveStatusDtoSchema = schemas.SingleValueResponseServiceLiveStatusDto +export const SingleValueResponseGlobalStatusSummaryDtoSchema = schemas.SingleValueResponseGlobalStatusSummaryDto +export const SingleValueResponseBatchComponentUptimeDtoSchema = schemas.SingleValueResponseBatchComponentUptimeDto +export const SingleValueResponseServiceDayDetailDtoSchema = schemas.SingleValueResponseServiceDayDetailDto +export const SingleValueResponseServiceIncidentDetailDtoSchema = schemas.SingleValueResponseServiceIncidentDetailDto +export const SingleValueResponseServiceUptimeResponseSchema = schemas.SingleValueResponseServiceUptimeResponse // TableValueResult (paginated) wrappers export const TableValueResultMonitorDtoSchema = schemas.TableValueResultMonitorDto @@ -158,9 +189,15 @@ export const TableValueResultStatusPageComponentGroupDtoSchema = schemas.TableVa export const TableValueResultStatusPageIncidentDtoSchema = schemas.TableValueResultStatusPageIncidentDto export const TableValueResultStatusPageSubscriberDtoSchema = schemas.TableValueResultStatusPageSubscriberDto export const TableValueResultStatusPageCustomDomainDtoSchema = schemas.TableValueResultStatusPageCustomDomainDto +export const TableValueResultCategoryDtoSchema = schemas.TableValueResultCategoryDto +export const TableValueResultServiceComponentDtoSchema = schemas.TableValueResultServiceComponentDto +export const TableValueResultComponentUptimeDayDtoSchema = schemas.TableValueResultComponentUptimeDayDto +export const TableValueResultServiceIncidentDtoSchema = schemas.TableValueResultServiceIncidentDto +export const TableValueResultScheduledMaintenanceDtoSchema = schemas.TableValueResultScheduledMaintenanceDto // CursorPage wrappers export const CursorPageCheckResultDtoSchema = schemas.CursorPageCheckResultDto +export const CursorPageServiceCatalogDtoSchema = schemas.CursorPageServiceCatalogDto // ── Generic pagination schema factory ─────────────────────────────── diff --git a/src/types.ts b/src/types.ts index 115316c..d390e0e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,28 @@ export type AssertionTestResultDto = z.infer export type MaintenanceWindowDto = z.infer +// ── Status Data catalog DTOs ────────────────────────────────────────── + +export type ServiceCatalogDto = z.infer +export type ServiceDetailDto = z.infer +export type ServiceLiveStatusDto = z.infer +export type ServiceStatusDto = z.infer +export type CategoryDto = z.infer +export type GlobalStatusSummaryDto = z.infer +export type ServiceComponentDto = z.infer +export type ComponentStatusDto = z.infer +export type ComponentsSummaryDto = z.infer +export type ComponentUptimeSummaryDto = z.infer +export type ComponentUptimeDayDto = z.infer +export type BatchComponentUptimeDto = z.infer +export type ServiceDayDetailDto = z.infer +export type ServiceIncidentDto = z.infer +export type ServiceIncidentDetailDto = z.infer +export type ServiceIncidentUpdateDto = z.infer +export type ServiceUptimeResponse = z.infer +export type UptimeBucketDto = z.infer +export type ScheduledMaintenanceDto = z.infer + // ── Forensic DTOs ───────────────────────────────────────────────────── export type IncidentTimelineDto = z.infer @@ -89,6 +111,8 @@ export type CreateApiKeyRequest = z.infer export type AcquireDeployLockRequest = z.infer export type CreateMaintenanceWindowRequest = z.infer export type UpdateMaintenanceWindowRequest = z.infer +export type ServiceSubscribeRequest = z.infer +export type UpdateAlertSensitivityRequest = z.infer // ── Status Page Request types ───────────────────────────────────────── diff --git a/test/dependencies.test.ts b/test/dependencies.test.ts new file mode 100644 index 0000000..d1a47cf --- /dev/null +++ b/test/dependencies.test.ts @@ -0,0 +1,141 @@ +/** + * Dependencies (service subscriptions) resource tests. + * + * Same fetch-stub approach as `test/maintenance-windows.test.ts`. Focused on + * the two body-carrying operations: `track` (optional ServiceSubscribeRequest + * body, omitted entirely when no options are given — preserving the original + * body-less behavior) and `updateAlertSensitivity` (PATCH with required body). + */ +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {Devhelm} from '../src/index.js' +import type {ServiceSubscriptionDto} from '../src/index.js' + +interface CapturedRequest { + method: string + url: URL + body: string | null +} + +const VALID_SUBSCRIPTION: ServiceSubscriptionDto = { + subscriptionId: '550e8400-e29b-41d4-a716-446655440000', + serviceId: '550e8400-e29b-41d4-a716-446655440001', + slug: 'github', + name: 'GitHub', + category: 'developer-tools', + officialStatusUrl: null, + adapterType: 'STATUSPAGE', + pollingIntervalSeconds: 60, + enabled: true, + logoUrl: null, + overallStatus: 'OPERATIONAL', + componentId: null, + component: null, + alertSensitivity: 'INCIDENTS_ONLY', + subscribedAt: '2026-06-01T00:00:00Z', +} + +describe('Dependencies resource', () => { + const originalFetch = globalThis.fetch + let captured: CapturedRequest[] = [] + let nextResponse: (req: CapturedRequest) => Response + + beforeEach(() => { + captured = [] + nextResponse = () => new Response(JSON.stringify({data: VALID_SUBSCRIPTION}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init) + const url = new URL(req.url) + const body = req.body ? await req.text() : null + const c: CapturedRequest = {method: req.method, url, body} + captured.push(c) + return nextResponse(c) + } + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function buildClient() { + return new Devhelm({token: 't', baseUrl: 'http://localhost:0'}) + } + + it('exposes dependencies with the expected methods', () => { + const c = buildClient() + expect(typeof c.dependencies.list).toBe('function') + expect(typeof c.dependencies.listPage).toBe('function') + expect(typeof c.dependencies.get).toBe('function') + expect(typeof c.dependencies.track).toBe('function') + expect(typeof c.dependencies.updateAlertSensitivity).toBe('function') + expect(typeof c.dependencies.delete).toBe('function') + }) + + it('track without options POSTs to /service-subscriptions/{slug} with no body', async () => { + const c = buildClient() + const result = await c.dependencies.track('github') + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('POST') + expect(req.url.pathname).toBe('/api/v1/service-subscriptions/github') + expect(req.body).toBeNull() + expect(result.subscriptionId).toBe(VALID_SUBSCRIPTION.subscriptionId) + }) + + it('track with options POSTs the ServiceSubscribeRequest body', async () => { + const c = buildClient() + const componentId = '550e8400-e29b-41d4-a716-446655440002' + const result = await c.dependencies.track('github', { + componentId, + alertSensitivity: 'MAJOR_ONLY', + }) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('POST') + expect(req.url.pathname).toBe('/api/v1/service-subscriptions/github') + expect(JSON.parse(req.body ?? '{}')).toEqual({ + componentId, + alertSensitivity: 'MAJOR_ONLY', + }) + expect(result.slug).toBe('github') + }) + + it('track rejects an invalid alertSensitivity before hitting the network', async () => { + const c = buildClient() + await expect( + c.dependencies.track('github', {alertSensitivity: 'SOMETIMES'}), + ).rejects.toThrow(/validation/i) + expect(captured).toHaveLength(0) + }) + + it('updateAlertSensitivity PATCHes /service-subscriptions/{id}/alert-sensitivity with the body', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: {...VALID_SUBSCRIPTION, alertSensitivity: 'MAJOR_ONLY'}, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const result = await c.dependencies.updateAlertSensitivity( + VALID_SUBSCRIPTION.subscriptionId, + 'MAJOR_ONLY', + ) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('PATCH') + expect(req.url.pathname).toBe(`/api/v1/service-subscriptions/${VALID_SUBSCRIPTION.subscriptionId}/alert-sensitivity`) + expect(JSON.parse(req.body ?? '{}')).toEqual({alertSensitivity: 'MAJOR_ONLY'}) + expect(result.alertSensitivity).toBe('MAJOR_ONLY') + }) + + it('updateAlertSensitivity rejects an invalid value before hitting the network', async () => { + const c = buildClient() + await expect( + c.dependencies.updateAlertSensitivity(VALID_SUBSCRIPTION.subscriptionId, 'LOUD'), + ).rejects.toThrow(/validation/i) + expect(captured).toHaveLength(0) + }) +}) diff --git a/test/services.test.ts b/test/services.test.ts new file mode 100644 index 0000000..74be91b --- /dev/null +++ b/test/services.test.ts @@ -0,0 +1,219 @@ +/** + * Services (Status Data catalog) resource tests. + * + * Same approach as `test/maintenance-windows.test.ts`: stub + * `globalThis.fetch`, capture the live `Request`, and assert on the exact + * path, method, and query string the SDK puts on the wire. + */ +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {Devhelm} from '../src/index.js' +import type {ServiceCatalogDto, ServiceIncidentDto} from '../src/index.js' + +interface CapturedRequest { + method: string + url: URL + body: string | null +} + +const VALID_SERVICE: ServiceCatalogDto = { + id: '550e8400-e29b-41d4-a716-446655440000', + slug: 'github', + name: 'GitHub', + category: 'developer-tools', + officialStatusUrl: 'https://www.githubstatus.com', + logoUrl: null, + adapterType: 'STATUSPAGE', + pollingIntervalSeconds: 60, + lifecycleStatus: 'ACTIVE', + enabled: true, + published: true, + overallStatus: 'OPERATIONAL', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-06-01T00:00:00Z', + componentCount: 12, + activeIncidentCount: 0, + dataCompleteness: 'FULL', + uptime30d: 99.98, +} + +const VALID_INCIDENT: ServiceIncidentDto = { + id: '550e8400-e29b-41d4-a716-446655440010', + serviceId: VALID_SERVICE.id, + serviceSlug: 'github', + serviceName: 'GitHub', + externalId: 'abc123', + title: 'Elevated error rates on Actions', + status: 'resolved', + impact: 'minor', + startedAt: '2026-05-20T10:00:00Z', + resolvedAt: '2026-05-20T11:30:00Z', + updatedAt: '2026-05-20T11:30:00Z', + shortlink: null, + detectedAt: '2026-05-20T10:01:00Z', + vendorCreatedAt: null, +} + +function pageEnvelope(data: unknown[]) { + return JSON.stringify({data, hasNext: false, hasPrev: false, totalElements: data.length, totalPages: 1}) +} + +describe('Services resource', () => { + const originalFetch = globalThis.fetch + let captured: CapturedRequest[] = [] + let nextResponse: (req: CapturedRequest) => Response + + beforeEach(() => { + captured = [] + nextResponse = () => new Response('{"data":null}', { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init) + const url = new URL(req.url) + const body = req.body ? await req.text() : null + const c: CapturedRequest = {method: req.method, url, body} + captured.push(c) + return nextResponse(c) + } + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function buildClient() { + return new Devhelm({token: 't', baseUrl: 'http://localhost:0'}) + } + + it('exposes services with the expected methods', () => { + const c = buildClient() + expect(c.services).toBeDefined() + for (const method of [ + 'list', 'get', 'liveStatus', 'categories', 'summary', 'components', + 'componentUptime', 'batchComponentUptime', 'day', 'incidents', + 'incident', 'uptime', 'maintenances', + ]) { + expect(typeof (c.services as unknown as Record)[method], method).toBe('function') + } + }) + + it('list forwards search and filters as query params and unwraps the cursor envelope', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: [VALID_SERVICE], + nextCursor: 'abc', + hasMore: true, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const page = await c.services.list({search: 'git', category: 'developer-tools', status: 'OPERATIONAL', limit: 10}) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('GET') + expect(req.url.pathname).toBe('/api/v1/services') + expect(req.url.searchParams.get('search')).toBe('git') + expect(req.url.searchParams.get('category')).toBe('developer-tools') + expect(req.url.searchParams.get('status')).toBe('OPERATIONAL') + expect(req.url.searchParams.get('limit')).toBe('10') + expect(req.url.searchParams.has('cursor')).toBe(false) + expect(page.data).toHaveLength(1) + expect(page.data[0].slug).toBe('github') + expect(page.nextCursor).toBe('abc') + expect(page.hasMore).toBe(true) + }) + + it('list forwards the cursor and omits unset filters', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: [], + nextCursor: null, + hasMore: false, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const page = await c.services.list({cursor: 'next-page-token'}) + + const [req] = captured + expect(req.url.searchParams.get('cursor')).toBe('next-page-token') + expect(req.url.searchParams.has('search')).toBe(false) + expect(req.url.searchParams.has('category')).toBe(false) + expect(page.nextCursor).toBeNull() + expect(page.hasMore).toBe(false) + }) + + it('get forwards the summary flag', async () => { + nextResponse = () => new Response(pageEnvelope([]), {status: 200, headers: {'Content-Type': 'application/json'}}) + const c = buildClient() + // Response shape validation is exercised elsewhere; here we only care + // about the request, so let the (invalid-shape) call reject. + await c.services.get('github', {summary: true}).catch(() => undefined) + + const [req] = captured + expect(req.method).toBe('GET') + expect(req.url.pathname).toBe('/api/v1/services/github') + expect(req.url.searchParams.get('summary')).toBe('true') + }) + + it('incidents with slugOrId hits the per-service path', async () => { + nextResponse = () => new Response(pageEnvelope([VALID_INCIDENT]), { + status: 200, headers: {'Content-Type': 'application/json'}, + }) + + const c = buildClient() + const page = await c.services.incidents({slugOrId: 'github', status: 'resolved', from: '2026-05-01T00:00:00Z'}) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.url.pathname).toBe('/api/v1/services/github/incidents') + expect(req.url.searchParams.get('status')).toBe('resolved') + expect(req.url.searchParams.get('from')).toBe('2026-05-01T00:00:00Z') + expect(req.url.searchParams.get('page')).toBe('0') + expect(req.url.searchParams.get('size')).toBe('20') + expect(page.data).toHaveLength(1) + expect(page.data[0].title).toBe(VALID_INCIDENT.title) + }) + + it('incidents without slugOrId hits the global path and forwards category', async () => { + nextResponse = () => new Response(pageEnvelope([VALID_INCIDENT]), { + status: 200, headers: {'Content-Type': 'application/json'}, + }) + + const c = buildClient() + const page = await c.services.incidents({category: 'developer-tools', page: 2, size: 50}) + + const [req] = captured + expect(req.url.pathname).toBe('/api/v1/services/incidents') + expect(req.url.searchParams.get('category')).toBe('developer-tools') + expect(req.url.searchParams.get('page')).toBe('2') + expect(req.url.searchParams.get('size')).toBe('50') + expect(page.totalElements).toBe(1) + }) + + it('categories unwraps the table envelope into a plain array', async () => { + nextResponse = () => new Response(pageEnvelope([ + {category: 'developer-tools', serviceCount: 42}, + {category: 'cloud', serviceCount: 17}, + ]), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const categories = await c.services.categories() + + const [req] = captured + expect(req.url.pathname).toBe('/api/v1/categories') + expect(req.url.search).toBe('') + expect(categories).toHaveLength(2) + expect(categories[0].category).toBe('developer-tools') + }) + + it('componentUptime builds the nested path and forwards the range query', async () => { + nextResponse = () => new Response(pageEnvelope([]), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const componentId = '550e8400-e29b-41d4-a716-446655440020' + await c.services.componentUptime('github', componentId, {period: '90d'}) + + const [req] = captured + expect(req.url.pathname).toBe(`/api/v1/services/github/components/${componentId}/uptime`) + expect(req.url.searchParams.get('period')).toBe('90d') + }) +}) diff --git a/test/spec-paths.test.ts b/test/spec-paths.test.ts index d6db375..0d164c9 100644 --- a/test/spec-paths.test.ts +++ b/test/spec-paths.test.ts @@ -127,6 +127,23 @@ const SDK_ENDPOINTS: ReadonlyArray = [ ['get', '/api/v1/service-subscriptions/{id}'], ['post', '/api/v1/service-subscriptions/{slug}'], ['delete', '/api/v1/service-subscriptions/{subscriptionId}'], + ['patch', '/api/v1/service-subscriptions/{id}/alert-sensitivity'], + + // services (Status Data catalog) + ['get', '/api/v1/services'], + ['get', '/api/v1/services/{slugOrId}'], + ['get', '/api/v1/services/{slugOrId}/live-status'], + ['get', '/api/v1/categories'], + ['get', '/api/v1/services/summary'], + ['get', '/api/v1/services/{slugOrId}/components'], + ['get', '/api/v1/services/{slugOrId}/components/{componentId}/uptime'], + ['get', '/api/v1/services/{slugOrId}/components/uptime'], + ['get', '/api/v1/services/{slugOrId}/days/{date}'], + ['get', '/api/v1/services/{slugOrId}/incidents'], + ['get', '/api/v1/services/incidents'], + ['get', '/api/v1/services/{slugOrId}/incidents/{incidentId}'], + ['get', '/api/v1/services/{slugOrId}/uptime'], + ['get', '/api/v1/services/{slugOrId}/maintenances'], // dashboard ['get', '/api/v1/dashboard/overview'],