diff --git a/CHANGELOG.md b/CHANGELOG.md index cb78d18..2ed8882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.15.0 + +Adds Push Notification dismissal. Pass a `notification_id` to `notifications.dismiss` to clear a Push Notification you already sent from the Lock Screen and Notification Center. History keeps it. Also adds `metrics.create` to create a Lock Screen Widget metric and set its label and format, such as currency or percent, and `devices.list` to list the iPhones and iPads paired to your account. + ## 1.14.0 Adds the ability to replace a Push Notification you already sent with a new one. diff --git a/generated/apis/DevicesApi.ts b/generated/apis/DevicesApi.ts new file mode 100644 index 0000000..fa370d6 --- /dev/null +++ b/generated/apis/DevicesApi.ts @@ -0,0 +1,82 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * ActivitySmith API + * Send push notifications and Live Activities to your own devices via a single API key. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + AccountDeviceListResponse, + ForbiddenError, + RateLimitError, +} from '../models/index'; + +export interface ListAccountDevicesRequest { + limit?: number; + offset?: number; + includeInactive?: boolean; +} + +/** + * + */ +export class DevicesApi extends runtime.BaseAPI { + + /** + * Lists the iPhones and iPads paired to the account, active devices first. Use it to check that a device is paired before you send, or to find device IDs for Channels. An empty list means no device is paired yet: install the ActivitySmith app from the App Store and sign in. Channel-scoped API keys cannot list devices. + * List paired devices + */ + async listAccountDevicesRaw(requestParameters: ListAccountDevicesRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + if (requestParameters['limit'] != null) { + queryParameters['limit'] = requestParameters['limit']; + } + + if (requestParameters['offset'] != null) { + queryParameters['offset'] = requestParameters['offset']; + } + + if (requestParameters['includeInactive'] != null) { + queryParameters['include_inactive'] = requestParameters['includeInactive']; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("apiKeyAuth", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + const response = await this.request({ + path: `/account/devices`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response); + } + + /** + * Lists the iPhones and iPads paired to the account, active devices first. Use it to check that a device is paired before you send, or to find device IDs for Channels. An empty list means no device is paired yet: install the ActivitySmith app from the App Store and sign in. Channel-scoped API keys cannot list devices. + * List paired devices + */ + async listAccountDevices(requestParameters: ListAccountDevicesRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.listAccountDevicesRaw(requestParameters, initOverrides); + return await response.value(); + } + +} diff --git a/generated/apis/LiveActivitiesApi.ts b/generated/apis/LiveActivitiesApi.ts index 69056ed..58f65a1 100644 --- a/generated/apis/LiveActivitiesApi.ts +++ b/generated/apis/LiveActivitiesApi.ts @@ -62,7 +62,7 @@ export interface UpdateLiveActivityRequest { export class LiveActivitiesApi extends runtime.BaseAPI { /** - * Legacy manual lifecycle endpoint. For new integrations, use DELETE /live-activity/stream/{stream_key} to end a managed Live Activity stream. This endpoint remains supported for existing integrations and advanced lifecycle control. Ends a Live Activity and archives its lifecycle. Supports segmented_progress, progress, metrics, stats, alert, timer, and value activity types. For segmented_progress activities, you can send the latest number_of_steps here if the workflow changed after start. Use secondary_action for a second button on alert, progress, segmented_progress, and value Live Activities. + * Legacy manual lifecycle endpoint. For new integrations, use DELETE /live-activity/stream/{stream_key} to end a managed Live Activity stream. This endpoint remains supported for existing integrations and advanced lifecycle control. Ends a Live Activity and archives its lifecycle. Supports segmented_progress, progress, metrics, stats, alert, timer, and value activity types. For segmented_progress activities, you can send the latest number_of_steps here if the workflow changed after start. Use secondary_action for a second button on alert, progress, segmented_progress, and value Live Activities. Ending a Live Activity that already ended returns 200 with ignored: true and sends nothing to devices, so retries are safe. * End a Live Activity (legacy manual lifecycle) */ async endLiveActivityRaw(requestParameters: EndLiveActivityRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { @@ -99,7 +99,7 @@ export class LiveActivitiesApi extends runtime.BaseAPI { } /** - * Legacy manual lifecycle endpoint. For new integrations, use DELETE /live-activity/stream/{stream_key} to end a managed Live Activity stream. This endpoint remains supported for existing integrations and advanced lifecycle control. Ends a Live Activity and archives its lifecycle. Supports segmented_progress, progress, metrics, stats, alert, timer, and value activity types. For segmented_progress activities, you can send the latest number_of_steps here if the workflow changed after start. Use secondary_action for a second button on alert, progress, segmented_progress, and value Live Activities. + * Legacy manual lifecycle endpoint. For new integrations, use DELETE /live-activity/stream/{stream_key} to end a managed Live Activity stream. This endpoint remains supported for existing integrations and advanced lifecycle control. Ends a Live Activity and archives its lifecycle. Supports segmented_progress, progress, metrics, stats, alert, timer, and value activity types. For segmented_progress activities, you can send the latest number_of_steps here if the workflow changed after start. Use secondary_action for a second button on alert, progress, segmented_progress, and value Live Activities. Ending a Live Activity that already ended returns 200 with ignored: true and sends nothing to devices, so retries are safe. * End a Live Activity (legacy manual lifecycle) */ async endLiveActivity(requestParameters: EndLiveActivityRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { diff --git a/generated/apis/PushNotificationsApi.ts b/generated/apis/PushNotificationsApi.ts index 46666ee..4765534 100644 --- a/generated/apis/PushNotificationsApi.ts +++ b/generated/apis/PushNotificationsApi.ts @@ -20,6 +20,7 @@ import type { ForbiddenError, NoRecipientsError, NotFoundError, + PushNotificationDismissResponse, PushNotificationReplaceRequest, PushNotificationReplaceResponse, PushNotificationRequest, @@ -27,6 +28,10 @@ import type { SendPushNotification429Response, } from '../models/index'; +export interface DismissPushNotificationRequest { + notificationId: string; +} + export interface ReplacePushNotificationRequest { notificationId: string; pushNotificationReplaceRequest: PushNotificationReplaceRequest; @@ -41,6 +46,49 @@ export interface SendPushNotificationRequest { */ export class PushNotificationsApi extends runtime.BaseAPI { + /** + * Clears a Push Notification you already sent from the Lock Screen and Notification Center on every device it was sent to, using the `notification_id` from the send response. Notification history keeps it. Devices clear the notification in the background, which can take a moment or wait until the ActivitySmith app next opens. A successful response means the dismissal was recorded, not that every device has cleared it yet. Devices running an ActivitySmith app version before 1.17.0 show a silent notification titled \"Push Notification dismissed\" in its place until the user clears it. Calling this again for the same notification returns the same response and sends nothing. A dismissed notification cannot be replaced. + * Dismiss a push notification + */ + async dismissPushNotificationRaw(requestParameters: DismissPushNotificationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['notificationId'] == null) { + throw new runtime.RequiredError( + 'notificationId', + 'Required parameter "notificationId" was null or undefined when calling dismissPushNotification().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("apiKeyAuth", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + const response = await this.request({ + path: `/push-notification/{notification_id}/dismiss`.replace(`{${"notification_id"}}`, encodeURIComponent(String(requestParameters['notificationId']))), + method: 'POST', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response); + } + + /** + * Clears a Push Notification you already sent from the Lock Screen and Notification Center on every device it was sent to, using the `notification_id` from the send response. Notification history keeps it. Devices clear the notification in the background, which can take a moment or wait until the ActivitySmith app next opens. A successful response means the dismissal was recorded, not that every device has cleared it yet. Devices running an ActivitySmith app version before 1.17.0 show a silent notification titled \"Push Notification dismissed\" in its place until the user clears it. Calling this again for the same notification returns the same response and sends nothing. A dismissed notification cannot be replaced. + * Dismiss a push notification + */ + async dismissPushNotification(requestParameters: DismissPushNotificationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.dismissPushNotificationRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Replaces a Push Notification you already sent, using the `notification_id` from the send response. The replacement takes the original\'s place on the Lock Screen and in Notification Center instead of appearing as a second notification. It is delivered silently, with no sound or screen wake, to the same recipients as the original. Notification history is updated in place. * Replace a push notification diff --git a/generated/apis/index.ts b/generated/apis/index.ts index ecb06b5..f5b8796 100644 --- a/generated/apis/index.ts +++ b/generated/apis/index.ts @@ -1,6 +1,7 @@ /* tslint:disable */ /* eslint-disable */ export * from './AppIconBadgesApi'; +export * from './DevicesApi'; export * from './LiveActivitiesApi'; export * from './MetricsApi'; export * from './PushNotificationsApi'; diff --git a/generated/models/index.ts b/generated/models/index.ts index 093a4ed..f8a00d1 100644 --- a/generated/models/index.ts +++ b/generated/models/index.ts @@ -1,5 +1,140 @@ /* tslint:disable */ /* eslint-disable */ +/** + * An iPhone or iPad paired to the account, one entry per device and owner. + * @export + * @interface AccountDevice + */ +export interface AccountDevice { + /** + * Email of the account member who paired the device. + * @type {string} + * @memberof AccountDevice + */ + user_email: string; + /** + * Device ID. Use it to assign the device to Channels. + * @type {string} + * @memberof AccountDevice + */ + device_id: string; + /** + * Device platform, such as ios. iPhones and iPads both report ios; use system_name to tell them apart. + * @type {string} + * @memberof AccountDevice + */ + device_type: string | null; + /** + * True when the device can receive Push Notifications or Live Activities. + * @type {boolean} + * @memberof AccountDevice + */ + is_active: boolean; + /** + * Whether each delivery token is active, keyed by token type (push, start_live_activity). + * @type {{ [key: string]: boolean; }} + * @memberof AccountDevice + */ + token_status: { [key: string]: boolean; }; + /** + * ActivitySmith app version on the device. + * @type {string} + * @memberof AccountDevice + */ + app_version: string | null; + /** + * Device name set on the device. + * @type {string} + * @memberof AccountDevice + */ + device_name: string | null; + /** + * Operating system name, such as iOS or iPadOS. + * @type {string} + * @memberof AccountDevice + */ + system_name: string | null; + /** + * Operating system version. + * @type {string} + * @memberof AccountDevice + */ + system_version: string | null; + /** + * When the device was first paired. + * @type {string} + * @memberof AccountDevice + */ + created_at: string | null; + /** + * When the device last updated its pairing. + * @type {string} + * @memberof AccountDevice + */ + updated_at: string | null; +} +/** + * + * @export + * @interface AccountDeviceListResponse + */ +export interface AccountDeviceListResponse { + /** + * + * @type {Array} + * @memberof AccountDeviceListResponse + */ + devices: Array; + /** + * + * @type {AccountDeviceListResponseMeta} + * @memberof AccountDeviceListResponse + */ + meta: AccountDeviceListResponseMeta; +} +/** + * + * @export + * @interface AccountDeviceListResponseMeta + */ +export interface AccountDeviceListResponseMeta { + /** + * Devices matching the filter. + * @type {number} + * @memberof AccountDeviceListResponseMeta + */ + total: number; + /** + * + * @type {number} + * @memberof AccountDeviceListResponseMeta + */ + limit: number; + /** + * + * @type {number} + * @memberof AccountDeviceListResponseMeta + */ + offset: number; + /** + * + * @type {boolean} + * @memberof AccountDeviceListResponseMeta + */ + has_more: boolean; + /** + * Offset for the next page, or null on the last page. + * @type {number} + * @memberof AccountDeviceListResponseMeta + */ + next_offset: number | null; + /** + * + * @type {boolean} + * @memberof AccountDeviceListResponseMeta + */ + include_inactive: boolean; +} /** * * @export @@ -2092,6 +2227,31 @@ export const PushNotificationActionType = { } as const; export type PushNotificationActionType = typeof PushNotificationActionType[keyof typeof PushNotificationActionType]; +/** + * Dismissal result. Devices clear the notification after this response, so it does not confirm that any device has cleared it. + * @export + * @interface PushNotificationDismissResponse + */ +export interface PushNotificationDismissResponse { + /** + * True when the dismissal is recorded. + * @type {boolean} + * @memberof PushNotificationDismissResponse + */ + success: boolean; + /** + * ID of the dismissed Push Notification. + * @type {string} + * @memberof PushNotificationDismissResponse + */ + notification_id: string; + /** + * When the notification was first dismissed. Repeat calls return the same time. + * @type {string} + * @memberof PushNotificationDismissResponse + */ + dismissed_at: string; +} /** * Replacement content. title, subtitle, message, redirection, media, icon, actions, and payload fully replace the original values. Omit metadata or tags to keep the stored values; send {} or [] to clear them. * @export diff --git a/generated/openapi-source.json b/generated/openapi-source.json index 498433c..6f55ecd 100644 --- a/generated/openapi-source.json +++ b/generated/openapi-source.json @@ -1,6 +1,6 @@ { "repository": "ActivitySmithHQ/activitysmith-backend", - "commit": "7bf9c3d88459fe3f122810979a45da0a1e6fd2d6", + "commit": "278d980633d4b1fc735ca96948fdd8ff8fd27642", "path": "openapi.json", - "sha256": "6987f90b106e28a0ab8058f43309e7a9f045c7c54d27ce90bbdd5966c3f99f7a" + "sha256": "0dbbba13bf497eceea4de0759943481a6e085cdf9a9a5445a60cf2b27457de40" } diff --git a/package-lock.json b/package-lock.json index fcc7db8..7473eab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "activitysmith", - "version": "1.14.0", + "version": "1.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "activitysmith", - "version": "1.14.0", + "version": "1.15.0", "license": "MIT", "devDependencies": { "typescript": "^5.3.3", diff --git a/package.json b/package.json index 5c13ad2..59be0d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activitysmith", - "version": "1.14.0", + "version": "1.15.0", "description": "Official ActivitySmith Node.js SDK", "keywords": [ "activitysmith", diff --git a/src/ActivitySmith.ts b/src/ActivitySmith.ts index 7181264..f94d352 100644 --- a/src/ActivitySmith.ts +++ b/src/ActivitySmith.ts @@ -1,13 +1,14 @@ import { AppIconBadgesApi, Configuration, + DevicesApi, LiveActivitiesApi, MetricsApi, PushInterruptionLevel, PushNotificationsApi, } from "../generated/index"; -const SDK_VERSION = "1.14.0"; +const SDK_VERSION = "1.15.0"; const SDK_HEADER_NAME = "X-ActivitySmith-SDK"; const DEFAULT_SDK_NAME = "node"; const SDK_HEADER_VALUE = `${DEFAULT_SDK_NAME}-v${SDK_VERSION}`; @@ -27,6 +28,7 @@ type SendInitOverrides = Parameters[0]["pushNotificationReplaceRequest"]; type PushReplaceParameters = Parameters[0]; +type PushDismissParameters = Parameters[0]; type StartRequestBody = Parameters[0]["liveActivityStartRequest"]; type UpdateRequestBody = Parameters[0]["liveActivityUpdateRequest"]; type EndRequestBody = Parameters[0]["liveActivityEndRequest"]; @@ -46,6 +48,8 @@ type MetricUpsertRawParameters = Parameters[0]; type MetricDefinition = NonNullable; type AppIconBadgeRequestBody = Parameters[0]["appIconBadgeCountUpdateRequest"]; +type DeviceListParameters = NonNullable[0]>; +type DeviceListInitOverrides = Parameters[1]; type AppIconBadgeInitOverrides = Parameters[1]; type ChannelTargetInput = { channels?: string[] }; type PushSendRequest = PushRequestBody & { channels?: string[] }; @@ -54,6 +58,15 @@ export type BadgeCountOptions = { channels?: string[]; }; +export type DeviceListOptions = { + /** Maximum number of devices to return. */ + limit?: number; + /** Number of devices to skip, for paging. */ + offset?: number; + /** Include devices that can no longer receive Push Notifications or Live Activities. */ + includeInactive?: boolean; +}; + const LiveActivityTypes = { segmentedProgress: "segmented_progress", progress: "progress", @@ -251,6 +264,19 @@ export class NotificationsResource { ); } + /** + * Clears a sent Push Notification from the Lock Screen and Notification Center, + * using the notification_id returned by send(). Notification history keeps it. + */ + dismiss(notificationId: string, initOverrides?: SendInitOverrides) { + return this.api.dismissPushNotification({ notificationId }, initOverrides); + } + + // Generated-style alias. + dismissPushNotification(requestParameters: PushDismissParameters, initOverrides?: SendInitOverrides) { + return this.api.dismissPushNotification(requestParameters, initOverrides); + } + // Generated-style alias. replacePushNotification(requestParameters: PushReplaceParameters, initOverrides?: SendInitOverrides) { assertValidPushRequest(requestParameters.pushNotificationReplaceRequest); @@ -437,6 +463,34 @@ export class MetricsResource { } } +export class DevicesResource { + private readonly api: DevicesApi; + + constructor(api: DevicesApi) { + this.api = api; + } + + /** + * Lists the iPhones and iPads paired to the account, active devices first. + * Pass includeInactive to also list devices that can no longer receive deliveries. + */ + list(options: DeviceListOptions = {}, initOverrides?: DeviceListInitOverrides) { + return this.api.listAccountDevices( + compactObject({ + limit: options.limit, + offset: options.offset, + includeInactive: options.includeInactive, + }), + initOverrides, + ); + } + + // Generated-style alias. + listAccountDevices(requestParameters: DeviceListParameters = {}, initOverrides?: DeviceListInitOverrides) { + return this.api.listAccountDevices(requestParameters, initOverrides); + } +} + export class ActivitySmith { public static readonly liveActivityTypes = LiveActivityTypes; public static readonly contentState = contentState; @@ -452,6 +506,7 @@ export class ActivitySmith { public readonly notifications: NotificationsResource; public readonly liveActivities: LiveActivitiesResource; public readonly metrics: MetricsResource; + public readonly devices: DevicesResource; private readonly appIconBadgesApi: AppIconBadgesApi; constructor(opts: ActivitySmithOptions) { @@ -470,6 +525,7 @@ export class ActivitySmith { this.notifications = new NotificationsResource(new PushNotificationsApi(config)); this.liveActivities = new LiveActivitiesResource(new LiveActivitiesApi(config)); this.metrics = new MetricsResource(new MetricsApi(config)); + this.devices = new DevicesResource(new DevicesApi(config)); this.appIconBadgesApi = new AppIconBadgesApi(config); } diff --git a/tests/resources.test.js b/tests/resources.test.js index 1416049..fe7dbdd 100644 --- a/tests/resources.test.js +++ b/tests/resources.test.js @@ -185,6 +185,53 @@ describe("resource wrappers", () => { ).toThrow("ActivitySmith: media cannot be combined with actions"); }); + it("wraps notification id for notifications.dismiss", async () => { + const ActivitySmith = require("../dist/src/index.js"); + const generated = require("../dist/generated/index.js"); + + const response = { success: true, notification_id: "pn1", dismissed_at: "2026-11-07T09:00:00.000Z" }; + const dismissSpy = vi + .spyOn(generated.PushNotificationsApi.prototype, "dismissPushNotification") + .mockResolvedValue(response); + + const client = new ActivitySmith({ apiKey: "test" }); + + expect(await client.notifications.dismiss("pn1")).toEqual(response); + expect(await client.notifications.dismissPushNotification({ notificationId: "pn2" })).toEqual(response); + expect(dismissSpy).toHaveBeenNthCalledWith(1, { notificationId: "pn1" }, undefined); + expect(dismissSpy).toHaveBeenNthCalledWith(2, { notificationId: "pn2" }, undefined); + }); + + it("sends notifications.dismiss as POST /push-notification/{id}/dismiss", async () => { + const ActivitySmith = require("../dist/src/index.js"); + let request; + vi.stubGlobal("fetch", async (url, init) => { + request = { url: String(url), method: init.method, body: init.body }; + return new Response( + JSON.stringify({ success: true, notification_id: "pn_123", dismissed_at: "2026-11-07T09:00:00.000Z" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + try { + const client = new ActivitySmith({ apiKey: "test" }); + const response = await client.notifications.dismiss("pn_123"); + + expect(request).toEqual({ + url: "https://activitysmith.com/api/push-notification/pn_123/dismiss", + method: "POST", + body: undefined, + }); + expect(response).toEqual({ + success: true, + notification_id: "pn_123", + dismissed_at: "2026-11-07T09:00:00.000Z", + }); + } finally { + vi.unstubAllGlobals(); + } + }); + it("keeps long notification alias working", async () => { const ActivitySmith = require("../dist/src/index.js"); const generated = require("../dist/generated/index.js"); @@ -885,6 +932,131 @@ describe("resource wrappers", () => { } }); + it("sends devices.list as GET /account/devices", async () => { + const ActivitySmith = require("../dist/src/index.js"); + const requests = []; + vi.stubGlobal("fetch", async (url, init) => { + const parsed = new URL(String(url)); + requests.push({ + path: parsed.pathname, + query: Object.fromEntries(parsed.searchParams), + method: init.method, + body: init.body, + }); + return new Response( + JSON.stringify({ + devices: [ + { + user_email: "owner@example.com", + device_id: "dev_1", + device_type: "ios", + is_active: true, + token_status: { push: true, start_live_activity: true }, + app_version: "1.17.0", + device_name: "iPhone", + system_name: "iOS", + system_version: "27.0", + created_at: "2026-11-01T09:00:00.000Z", + updated_at: "2026-11-07T09:00:00.000Z", + }, + ], + meta: { total: 1, limit: 50, offset: 0, has_more: false, next_offset: null, include_inactive: false }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + try { + const client = new ActivitySmith({ apiKey: "test" }); + const response = await client.devices.list(); + await client.devices.list({ includeInactive: true, limit: 10 }); + await client.devices.list({ offset: 20 }); + await client.devices.listAccountDevices({ includeInactive: false }); + + expect(requests).toEqual([ + { path: "/api/account/devices", query: {}, method: "GET", body: undefined }, + { path: "/api/account/devices", query: { include_inactive: "true", limit: "10" }, method: "GET", body: undefined }, + { path: "/api/account/devices", query: { offset: "20" }, method: "GET", body: undefined }, + { path: "/api/account/devices", query: { include_inactive: "false" }, method: "GET", body: undefined }, + ]); + expect(response.devices[0].device_name).toBe("iPhone"); + expect(response.meta.next_offset).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("runs the dismissal, devices, and metrics doc examples", async () => { + const ActivitySmith = require("../dist/src/index.js"); + const requests = []; + vi.stubGlobal("fetch", async (url, init) => { + const parsed = new URL(String(url)); + const path = parsed.pathname.replace(/^\/api/, ""); + requests.push(`${init.method} ${path}${parsed.search}`); + const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + + if (init.method === "POST" && path === "/push-notification") { + return json({ success: true, notification_id: "pn_123", devices_notified: 1, users_notified: 1 }); + } + if (init.method === "POST" && path === "/push-notification/pn_123/dismiss") { + return json({ success: true, notification_id: "pn_123", dismissed_at: "2026-11-07T09:00:00.000Z" }); + } + if (init.method === "GET" && path === "/account/devices") { + return json({ + devices: [ + { + user_email: "owner@example.com", + device_id: "dev_1", + device_type: "ios", + is_active: true, + token_status: { push: true }, + app_version: "1.17.0", + device_name: "Adam's iPhone", + system_name: "iOS", + system_version: "27.0", + created_at: null, + updated_at: null, + }, + ], + meta: { total: 1, limit: 50, offset: 0, has_more: false, next_offset: null, include_inactive: false }, + }); + } + if (init.method === "PUT" && path === "/metrics/mrr") { + return json({ metric: { key: "mrr", label: "MRR", format: "currency", currency_code: "USD" } }, 201); + } + if (init.method === "POST" && path === "/metrics/mrr/value") { + return json({ metric: { key: "mrr", latest_value: 12450 } }); + } + throw new Error(`unexpected request ${init.method} ${url}`); + }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const activitysmith = new ActivitySmith({ apiKey: "test" }); + + const sent = await activitysmith.notifications.send({ title: "🙋 Agent needs your input", message: "Approve the database migration?" }); + await activitysmith.notifications.dismiss(sent.notification_id); + const { devices } = await activitysmith.devices.list(); + for (const device of devices) { console.log(device.device_name, device.system_name, device.is_active); } + await activitysmith.devices.list({ includeInactive: true }); + await activitysmith.metrics.create("mrr", { label: "MRR", format: "currency", currency_code: "USD" }); + await activitysmith.metrics.update("mrr", 12450); + + expect(log).toHaveBeenCalledWith("Adam's iPhone", "iOS", true); + expect(requests).toEqual([ + "POST /push-notification", + "POST /push-notification/pn_123/dismiss", + "GET /account/devices", + "GET /account/devices?include_inactive=true", + "PUT /metrics/mrr", + "POST /metrics/mrr/value", + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + it("keeps long metric aliases working", async () => { const ActivitySmith = require("../dist/src/index.js"); const generated = require("../dist/generated/index.js");