From 4f395bb378ee047b6c95b8eee9a2d47bf3311812 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 17 Oct 2025 20:34:24 -0700 Subject: [PATCH] Add support for the Retention Messaging API 1.0-1.1 https://developer.apple.com/documentation/retentionmessaging --- README.md | 2 +- index.ts | 260 +++++++++++++++-- jws_verification.ts | 21 ++ models/AlternateProduct.ts | 23 ++ models/DecodedRealtimeRequestBody.ts | 91 ++++++ models/DefaultConfigurationRequest.ts | 16 ++ models/GetImageListResponse.ts | 36 +++ models/GetImageListResponseItem.ts | 39 +++ models/GetMessageListResponse.ts | 36 +++ models/GetMessageListResponseItem.ts | 39 +++ models/ImageState.ts | 16 ++ models/Message.ts | 16 ++ models/MessageState.ts | 16 ++ models/PromotionalOffer.ts | 32 +++ models/PromotionalOfferSignatureV1.ts | 52 ++++ models/RealtimeRequestBody.ts | 28 ++ models/RealtimeResponseBody.ts | 34 +++ models/UploadMessageImage.ts | 25 ++ models/UploadMessageRequestBody.ts | 36 +++ .../models/decodedRealtimeRequest.json | 9 + .../models/getImageListResponse.json | 8 + .../models/getMessageListResponse.json | 8 + .../DecodedRealtimeRequestBody.test.ts | 20 ++ tests/unit-tests/RealtimeResponseBody.test.ts | 206 ++++++++++++++ tests/unit-tests/api_client.test.ts | 266 ++++++++++++++---- 25 files changed, 1259 insertions(+), 76 deletions(-) create mode 100644 models/AlternateProduct.ts create mode 100644 models/DecodedRealtimeRequestBody.ts create mode 100644 models/DefaultConfigurationRequest.ts create mode 100644 models/GetImageListResponse.ts create mode 100644 models/GetImageListResponseItem.ts create mode 100644 models/GetMessageListResponse.ts create mode 100644 models/GetMessageListResponseItem.ts create mode 100644 models/ImageState.ts create mode 100644 models/Message.ts create mode 100644 models/MessageState.ts create mode 100644 models/PromotionalOffer.ts create mode 100644 models/PromotionalOfferSignatureV1.ts create mode 100644 models/RealtimeRequestBody.ts create mode 100644 models/RealtimeResponseBody.ts create mode 100644 models/UploadMessageImage.ts create mode 100644 models/UploadMessageRequestBody.ts create mode 100644 tests/resources/models/decodedRealtimeRequest.json create mode 100644 tests/resources/models/getImageListResponse.json create mode 100644 tests/resources/models/getMessageListResponse.json create mode 100644 tests/unit-tests/DecodedRealtimeRequestBody.test.ts create mode 100644 tests/unit-tests/RealtimeResponseBody.test.ts diff --git a/README.md b/README.md index e3b01a6..f194b72 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Apple App Store Server Node.js Library -The Node.js server library for the [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi) and [App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications). Also available in [Swift](https://github.com/apple/app-store-server-library-swift), [Python](https://github.com/apple/app-store-server-library-python), and [Java](https://github.com/apple/app-store-server-library-java). +The Node.js server library for the [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi), [App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications), and [Retention Messaging API](https://developer.apple.com/documentation/retentionmessaging). Also available in [Swift](https://github.com/apple/app-store-server-library-swift), [Python](https://github.com/apple/app-store-server-library-python), and [Java](https://github.com/apple/app-store-server-library-java). ## Table of Contents 1. [Installation](#installation) diff --git a/index.ts b/index.ts index e8263d4..aa2675e 100644 --- a/index.ts +++ b/index.ts @@ -4,9 +4,12 @@ import fetch from 'node-fetch'; import { CheckTestNotificationResponse, CheckTestNotificationResponseValidator } from './models/CheckTestNotificationResponse'; import { ConsumptionRequest } from './models/ConsumptionRequest'; import { UpdateAppAccountTokenRequest } from './models/UpdateAppAccountTokenRequest' +import { DefaultConfigurationRequest } from './models/DefaultConfigurationRequest'; import { Environment } from './models/Environment'; import { ExtendRenewalDateRequest } from './models/ExtendRenewalDateRequest'; import { ExtendRenewalDateResponse, ExtendRenewalDateResponseValidator } from './models/ExtendRenewalDateResponse'; +import { GetImageListResponse, GetImageListResponseValidator } from './models/GetImageListResponse'; +import { GetMessageListResponse, GetMessageListResponseValidator } from './models/GetMessageListResponse'; import { HistoryResponse, HistoryResponseValidator } from './models/HistoryResponse'; import { MassExtendRenewalDateRequest } from './models/MassExtendRenewalDateRequest'; import { MassExtendRenewalDateResponse, MassExtendRenewalDateResponseValidator } from './models/MassExtendRenewalDateResponse'; @@ -17,26 +20,35 @@ import { SendTestNotificationResponse, SendTestNotificationResponseValidator } f import { StatusResponse, StatusResponseValidator } from './models/StatusResponse'; import { TransactionHistoryRequest } from './models/TransactionHistoryRequest'; import { TransactionInfoResponse, TransactionInfoResponseValidator } from './models/TransactionInfoResponse'; +import { UploadMessageRequestBody } from './models/UploadMessageRequestBody'; import { Validator } from './models/Validator'; import { Status } from './models/Status'; export { SignedDataVerifier, VerificationException, VerificationStatus } from './jws_verification' export { ReceiptUtility } from './receipt_utility' export { AccountTenure } from "./models/AccountTenure" +export { AlternateProduct } from './models/AlternateProduct' export { AutoRenewStatus } from './models/AutoRenewStatus' export { CheckTestNotificationResponse } from './models/CheckTestNotificationResponse' export { ConsumptionRequest } from './models/ConsumptionRequest' export { UpdateAppAccountTokenRequest } from './models/UpdateAppAccountTokenRequest' export { ConsumptionStatus } from './models/ConsumptionStatus' export { Data } from './models/Data' +export { DecodedRealtimeRequestBody } from './models/DecodedRealtimeRequestBody' +export { DefaultConfigurationRequest } from './models/DefaultConfigurationRequest' export { DeliveryStatus } from './models/DeliveryStatus' export { Environment } from './models/Environment' export { ExpirationIntent } from './models/ExpirationIntent' export { ExtendReasonCode } from './models/ExtendReasonCode' export { ExtendRenewalDateRequest } from './models/ExtendRenewalDateRequest' export { ExtendRenewalDateResponse } from './models/ExtendRenewalDateResponse' +export { GetImageListResponse } from './models/GetImageListResponse' +export { GetImageListResponseItem } from './models/GetImageListResponseItem' +export { GetMessageListResponse } from './models/GetMessageListResponse' +export { GetMessageListResponseItem } from './models/GetMessageListResponseItem' export { SendAttemptResult } from './models/SendAttemptResult' export { SendAttemptItem } from './models/SendAttemptItem' export { HistoryResponse } from './models/HistoryResponse' +export { ImageState } from './models/ImageState' export { InAppOwnershipType } from './models/InAppOwnershipType' export { JWSRenewalInfoDecodedPayload } from './models/JWSRenewalInfoDecodedPayload' export { JWSTransactionDecodedPayload } from './models/JWSTransactionDecodedPayload' @@ -46,6 +58,8 @@ export { LifetimeDollarsRefunded } from './models/LifetimeDollarsRefunded' export { MassExtendRenewalDateRequest } from './models/MassExtendRenewalDateRequest' export { MassExtendRenewalDateResponse } from './models/MassExtendRenewalDateResponse' export { MassExtendRenewalDateStatusResponse } from './models/MassExtendRenewalDateStatusResponse' +export { Message } from './models/Message' +export { MessageState } from './models/MessageState' export { NotificationHistoryRequest } from './models/NotificationHistoryRequest' export { NotificationHistoryResponse } from './models/NotificationHistoryResponse' export { NotificationHistoryResponseItem } from './models/NotificationHistoryResponseItem' @@ -57,7 +71,11 @@ export { OrderLookupStatus } from './models/OrderLookupStatus' export { Platform } from './models/Platform' export { PlayTime } from './models/PlayTime' export { PriceIncreaseStatus } from './models/PriceIncreaseStatus' +export { PromotionalOffer } from './models/PromotionalOffer' +export { PromotionalOfferSignatureV1 } from './models/PromotionalOfferSignatureV1' export { PurchasePlatform } from './models/PurchasePlatform' +export { RealtimeRequestBody } from './models/RealtimeRequestBody' +export { RealtimeResponseBody } from './models/RealtimeResponseBody' export { RefundHistoryResponse } from './models/RefundHistoryResponse' export { ResponseBodyV2 } from './models/ResponseBodyV2' export { ResponseBodyV2DecodedPayload } from './models/ResponseBodyV2DecodedPayload' @@ -72,6 +90,8 @@ export { TransactionHistoryRequest, Order, ProductType } from './models/Transact export { TransactionInfoResponse } from './models/TransactionInfoResponse' export { TransactionReason } from './models/TransactionReason' export { Type } from './models/Type' +export { UploadMessageImage } from './models/UploadMessageImage' +export { UploadMessageRequestBody } from './models/UploadMessageRequestBody' export { UserStatus } from './models/UserStatus' export { PromotionalOfferSignatureCreator } from './promotional_offer' export { PromotionalOfferV2SignatureCreator, AdvancedCommerceInAppSignatureCreator, AdvancedCommerceInAppRequest, IntroductoryOfferEligibilitySignatureCreator } from './jws_signature_creator' @@ -124,7 +144,7 @@ export class AppStoreServerAPIClient { } } - protected async makeRequest(path: string, method: string, queryParameters: { [key: string]: string[]}, body: object | null, validator: Validator | null): Promise { + protected async makeRequest(path: string, method: string, queryParameters: { [key: string]: string[]}, body: object | Buffer | null, validator: Validator | null, contentType?: string): Promise { const headers: { [key: string]: string } = { 'User-Agent': AppStoreServerAPIClient.USER_AGENT, 'Authorization': 'Bearer ' + this.createBearerToken(), @@ -136,13 +156,18 @@ export class AppStoreServerAPIClient { parsedQueryParameters.append(queryParam, queryVal) } } - let stringBody = undefined - if (body != null) { - stringBody = JSON.stringify(body) + let requestBody: string | Buffer | undefined = undefined + if (body instanceof Buffer) { + requestBody = body + if (contentType) { + headers['Content-Type'] = contentType + } + } else if (body != null) { + requestBody = JSON.stringify(body) headers['Content-Type'] = 'application/json' } - const response = await this.makeFetchRequest(path, parsedQueryParameters, method, stringBody, headers) + const response = await this.makeFetchRequest(path, parsedQueryParameters, method, requestBody, headers) if(response.ok) { // Success @@ -178,10 +203,10 @@ export class AppStoreServerAPIClient { } } - protected async makeFetchRequest(path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) { + protected async makeFetchRequest(path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) { return await fetch(this.urlBase + path + '?' + parsedQueryParameters, { method: method, - body: stringBody, + body: requestBody, headers: headers }); } @@ -195,7 +220,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/extend_subscription_renewal_dates_for_all_active_subscribers Extend Subscription Renewal Dates for All Active Subscribers} */ public async extendRenewalDateForAllActiveSubscribers(massExtendRenewalDateRequest: MassExtendRenewalDateRequest): Promise { - return await this.makeRequest("/inApps/v1/subscriptions/extend/mass", "POST", {}, massExtendRenewalDateRequest, new MassExtendRenewalDateResponseValidator()); + return await this.makeRequest("/inApps/v1/subscriptions/extend/mass", "POST", {}, massExtendRenewalDateRequest, new MassExtendRenewalDateResponseValidator(), 'application/json'); } /** @@ -208,7 +233,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/extend_a_subscription_renewal_date Extend a Subscription Renewal Date} */ public async extendSubscriptionRenewalDate(originalTransactionId: string, extendRenewalDateRequest: ExtendRenewalDateRequest): Promise { - return await this.makeRequest("/inApps/v1/subscriptions/extend/" + originalTransactionId, "PUT", {}, extendRenewalDateRequest, new ExtendRenewalDateResponseValidator()); + return await this.makeRequest("/inApps/v1/subscriptions/extend/" + originalTransactionId, "PUT", {}, extendRenewalDateRequest, new ExtendRenewalDateResponseValidator(), 'application/json'); } /** @@ -226,7 +251,7 @@ export class AppStoreServerAPIClient { queryParameters["status"] = status.map(s => s.toString()) as [string]; } - return await this.makeRequest("/inApps/v1/subscriptions/" + transactionId, "GET", queryParameters, null, new StatusResponseValidator()); + return await this.makeRequest("/inApps/v1/subscriptions/" + transactionId, "GET", queryParameters, null, new StatusResponseValidator(), undefined); } /** @@ -244,7 +269,7 @@ export class AppStoreServerAPIClient { queryParameters["revision"] = [revision]; } - return await this.makeRequest("/inApps/v2/refund/lookup/" + transactionId, "GET", queryParameters, null, new RefundHistoryResponseValidator()); + return await this.makeRequest("/inApps/v2/refund/lookup/" + transactionId, "GET", queryParameters, null, new RefundHistoryResponseValidator(), undefined); } /** @@ -257,7 +282,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions Get Status of Subscription Renewal Date Extensions} */ public async getStatusOfSubscriptionRenewalDateExtensions(requestIdentifier: string, productId: string): Promise { - return await this.makeRequest("/inApps/v1/subscriptions/extend/mass/" + productId + "/" + requestIdentifier, "GET", {}, null, new MassExtendRenewalDateStatusResponseValidator()); + return await this.makeRequest("/inApps/v1/subscriptions/extend/mass/" + productId + "/" + requestIdentifier, "GET", {}, null, new MassExtendRenewalDateStatusResponseValidator(), undefined); } /** @@ -269,7 +294,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status Get Test Notification Status} */ public async getTestNotificationStatus(testNotificationToken: string): Promise { - return await this.makeRequest("/inApps/v1/notifications/test/" + testNotificationToken, "GET", {}, null, new CheckTestNotificationResponseValidator()); + return await this.makeRequest("/inApps/v1/notifications/test/" + testNotificationToken, "GET", {}, null, new CheckTestNotificationResponseValidator(), undefined); } /** @@ -286,7 +311,7 @@ export class AppStoreServerAPIClient { if (paginationToken != null) { queryParameters["paginationToken"] = [paginationToken]; } - return await this.makeRequest("/inApps/v1/notifications/history", "POST", queryParameters, notificationHistoryRequest, new NotificationHistoryResponseValidator()); + return await this.makeRequest("/inApps/v1/notifications/history", "POST", queryParameters, notificationHistoryRequest, new NotificationHistoryResponseValidator(), 'application/json'); } /** @@ -328,7 +353,7 @@ export class AppStoreServerAPIClient { if (transactionHistoryRequest.revoked !== undefined) { queryParameters["revoked"] = [transactionHistoryRequest.revoked.toString()]; } - return await this.makeRequest("/inApps/" + version + "/history/" + transactionId, "GET", queryParameters, null, new HistoryResponseValidator()); + return await this.makeRequest("/inApps/" + version + "/history/" + transactionId, "GET", queryParameters, null, new HistoryResponseValidator(), undefined); } /** @@ -340,7 +365,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info Get Transaction Info} */ public async getTransactionInfo(transactionId: string): Promise { - return await this.makeRequest("/inApps/v1/transactions/" + transactionId, "GET", {}, null, new TransactionInfoResponseValidator()); + return await this.makeRequest("/inApps/v1/transactions/" + transactionId, "GET", {}, null, new TransactionInfoResponseValidator(), undefined); } /** @@ -352,7 +377,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id Look Up Order ID} */ public async lookUpOrderId(orderId: string): Promise { - return await this.makeRequest("/inApps/v1/lookup/" + orderId, "GET", {}, null, new OrderLookupResponseValidator()); + return await this.makeRequest("/inApps/v1/lookup/" + orderId, "GET", {}, null, new OrderLookupResponseValidator(), undefined); } /** @@ -363,7 +388,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification Request a Test Notification} */ public async requestTestNotification(): Promise { - return await this.makeRequest("/inApps/v1/notifications/test", "POST", {}, null, new SendTestNotificationResponseValidator()); + return await this.makeRequest("/inApps/v1/notifications/test", "POST", {}, null, new SendTestNotificationResponseValidator(), undefined); } /** @@ -375,7 +400,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information Send Consumption Information} */ public async sendConsumptionData(transactionId: string, consumptionRequest: ConsumptionRequest): Promise { - await this.makeRequest("/inApps/v1/transactions/consumption/" + transactionId, "PUT", {}, consumptionRequest, null); + await this.makeRequest("/inApps/v1/transactions/consumption/" + transactionId, "PUT", {}, consumptionRequest, null, 'application/json'); } /** @@ -387,7 +412,100 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/set-app-account-token Set App Account Token} */ public async setAppAccountToken(originalTransactionId: string, updateAppAccountTokenRequest: UpdateAppAccountTokenRequest): Promise { - await this.makeRequest("/inApps/v1/transactions/" + originalTransactionId + "/appAccountToken", "PUT", {}, updateAppAccountTokenRequest, null); + await this.makeRequest("/inApps/v1/transactions/" + originalTransactionId + "/appAccountToken", "PUT", {}, updateAppAccountTokenRequest, null, 'application/json'); + } + + /** + * Upload an image to use for retention messaging. + * + * @param imageIdentifier A UUID you provide to uniquely identify the image you upload. Must be lowercase. + * @param image The image file to upload. + * @throws APIException If a response was returned indicating the request could not be processed + * {@link https://developer.apple.com/documentation/retentionmessaging/upload-image Upload Image} + */ + public async uploadImage(imageIdentifier: string, image: Buffer): Promise { + await this.makeRequest("/inApps/v1/messaging/image/" + imageIdentifier, "PUT", {}, image, null, 'image/png'); + } + + /** + * Delete a previously uploaded image. + * + * @param imageIdentifier The identifier of the image to delete. + * @throws APIException If a response was returned indicating the request could not be processed + * {@link https://developer.apple.com/documentation/retentionmessaging/delete-image Delete Image} + */ + public async deleteImage(imageIdentifier: string): Promise { + await this.makeRequest("/inApps/v1/messaging/image/" + imageIdentifier, "DELETE", {}, null, null, undefined); + } + + /** + * Get the image identifier and state for all uploaded images. + * + * @return A response that contains status information for all images. + * @throws APIException If a response was returned indicating the request could not be processed + * {@link https://developer.apple.com/documentation/retentionmessaging/get-image-list Get Image List} + */ + public async getImageList(): Promise { + return await this.makeRequest("/inApps/v1/messaging/image/list", "GET", {}, null, new GetImageListResponseValidator(), undefined); + } + + /** + * Upload a message to use for retention messaging. + * + * @param messageIdentifier A UUID you provide to uniquely identify the message you upload. Must be lowercase. + * @param uploadMessageRequestBody The message text to upload. + * @throws APIException If a response was returned indicating the request could not be processed + * {@link https://developer.apple.com/documentation/retentionmessaging/upload-message Upload Message} + */ + public async uploadMessage(messageIdentifier: string, uploadMessageRequestBody: UploadMessageRequestBody): Promise { + await this.makeRequest("/inApps/v1/messaging/message/" + messageIdentifier, "PUT", {}, uploadMessageRequestBody, null, 'application/json'); + } + + /** + * Delete a previously uploaded message. + * + * @param messageIdentifier The identifier of the message to delete. + * @throws APIException If a response was returned indicating the request could not be processed + * {@link https://developer.apple.com/documentation/retentionmessaging/delete-message Delete Message} + */ + public async deleteMessage(messageIdentifier: string): Promise { + await this.makeRequest("/inApps/v1/messaging/message/" + messageIdentifier, "DELETE", {}, null, null, undefined); + } + + /** + * Get the message identifier and state of all uploaded messages. + * + * @return A response that contains status information for all messages. + * @throws APIException If a response was returned indicating the request could not be processed + * {@link https://developer.apple.com/documentation/retentionmessaging/get-message-list Get Message List} + */ + public async getMessageList(): Promise { + return await this.makeRequest("/inApps/v1/messaging/message/list", "GET", {}, null, new GetMessageListResponseValidator(), undefined); + } + + /** + * Configure a default message for a specific product in a specific locale. + * + * @param productId The product identifier for the default configuration. + * @param locale The locale for the default configuration. + * @param defaultConfigurationRequest The request body that includes the message identifier to configure as the default message. + * @throws APIException If a response was returned indicating the request could not be processed + * {@link https://developer.apple.com/documentation/retentionmessaging/configure-default-message Configure Default Message} + */ + public async configureDefaultMessage(productId: string, locale: string, defaultConfigurationRequest: DefaultConfigurationRequest): Promise { + await this.makeRequest("/inApps/v1/messaging/default/" + productId + "/" + locale, "PUT", {}, defaultConfigurationRequest, null, 'application/json'); + } + + /** + * Delete a default message for a product in a locale. + * + * @param productId The product ID of the default message configuration. + * @param locale The locale of the default message configuration. + * @throws APIException If a response was returned indicating the request could not be processed + * {@link https://developer.apple.com/documentation/retentionmessaging/delete-default-message Delete Default Message} + */ + public async deleteDefaultMessage(productId: string, locale: string): Promise { + await this.makeRequest("/inApps/v1/messaging/default/" + productId + "/" + locale, "DELETE", {}, null, null, undefined); } private createBearerToken(): string { @@ -709,6 +827,41 @@ export enum APIError { */ APP_TRANSACTION_ID_NOT_SUPPORTED_ERROR = 4000048, + /** + * An error that indicates the image that's uploading is invalid. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/invalidimageerror InvalidImageError} + */ + INVALID_IMAGE = 4000161, + + /** + * An error that indicates the header text is too long. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/headertoolongerror HeaderTooLongError} + */ + HEADER_TOO_LONG = 4000162, + + /** + * An error that indicates the body text is too long. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/bodytoolongerror BodyTooLongError} + */ + BODY_TOO_LONG = 4000163, + + /** + * An error that indicates the locale is invalid. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/invalidlocaleerror InvalidLocaleError} + */ + INVALID_LOCALE = 4000164, + + /** + * An error that indicates the alternative text for an image is too long. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/alttexttoolongerror AltTextTooLongError} + */ + ALT_TEXT_TOO_LONG = 4000175, + /** * An error that indicates the app account token value is not a valid UUID. * @@ -752,8 +905,43 @@ export enum APIError { FAMILY_SHARED_SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030007, /** - * An error that indicates the App Store account wasn’t found. - * + * An error that indicates when you reach the maximum number of uploaded images. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/maximumnumberofimagesreachederror MaximumNumberOfImagesReachedError} + */ + MAXIMUM_NUMBER_OF_IMAGES_REACHED = 4030014, + + /** + * An error that indicates when you reach the maximum number of uploaded messages. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/maximumnumberofmessagesreachederror MaximumNumberOfMessagesReachedError} + */ + MAXIMUM_NUMBER_OF_MESSAGES_REACHED = 4030016, + + /** + * An error that indicates the message isn't in the approved state, so you can't configure it as a default message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messagenotapprovederror MessageNotApprovedError} + */ + MESSAGE_NOT_APPROVED = 4030017, + + /** + * An error that indicates the image isn't in the approved state, so you can't configure it as part of a default message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/imagenotapprovederror ImageNotApprovedError} + */ + IMAGE_NOT_APPROVED = 4030018, + + /** + * An error that indicates the image is currently in use as part of a message, so you can't delete it. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/imageinuseerror ImageInUseError} + */ + IMAGE_IN_USE = 4030019, + + /** + * An error that indicates the App Store account wasn't found. + * * {@link https://developer.apple.com/documentation/appstoreserverapi/accountnotfounderror AccountNotFoundError} */ ACCOUNT_NOT_FOUND = 4040001, @@ -821,6 +1009,34 @@ export enum APIError { */ TRANSACTION_ID_NOT_FOUND = 4040010, + /** + * An error that indicates the system can't find the image identifier. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/imagenotfounderror ImageNotFoundError} + */ + IMAGE_NOT_FOUND = 4040014, + + /** + * An error that indicates the system can't find the message identifier. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messagenotfounderror MessageNotFoundError} + */ + MESSAGE_NOT_FOUND = 4040015, + + /** + * An error that indicates the image identifier already exists. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/imagealreadyexistserror ImageAlreadyExistsError} + */ + IMAGE_ALREADY_EXISTS = 4090000, + + /** + * An error that indicates the message identifier already exists. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messagealreadyexistserror MessageAlreadyExistsError} + */ + MESSAGE_ALREADY_EXISTS = 4090001, + /** * An error that indicates that the request exceeded the rate limit. * diff --git a/jws_verification.ts b/jws_verification.ts index 2ca0112..0d555dd 100644 --- a/jws_verification.ts +++ b/jws_verification.ts @@ -10,6 +10,7 @@ import { Environment } from './models/Environment'; import { JWSTransactionDecodedPayload, JWSTransactionDecodedPayloadValidator } from './models/JWSTransactionDecodedPayload'; import { ResponseBodyV2DecodedPayload, ResponseBodyV2DecodedPayloadValidator } from './models/ResponseBodyV2DecodedPayload'; import { JWSRenewalInfoDecodedPayload, JWSRenewalInfoDecodedPayloadValidator } from './models/JWSRenewalInfoDecodedPayload'; +import { DecodedRealtimeRequestBody, DecodedRealtimeRequestBodyValidator } from './models/DecodedRealtimeRequestBody'; import { Validator } from './models/Validator'; import { DecodedSignedData } from './models/DecodedSignedData'; import { AppTransaction, AppTransactionValidator } from './models/AppTransaction'; @@ -49,6 +50,7 @@ export class SignedDataVerifier { private JWSTransactionDecodedPayloadValidator = new JWSTransactionDecodedPayloadValidator() private responseBodyV2DecodedPayloadValidator = new ResponseBodyV2DecodedPayloadValidator() private appTransactionValidator = new AppTransactionValidator() + private decodedRealtimeRequestBodyValidator = new DecodedRealtimeRequestBodyValidator() protected rootCertificates: X509Certificate[] protected enableOnlineChecks: boolean @@ -176,6 +178,25 @@ export class SignedDataVerifier { return decodedAppTransaction } + /** + * Verifies and decodes a Retention Messaging API signedPayload + * See {@link https://developer.apple.com/documentation/retentionmessaging/signedpayload signedPayload} + * + * @param signedPayload The payload received by your server + * @returns The decoded payload after verification + * @throws VerificationException Thrown if the data could not be verified + */ + async verifyAndDecodeRealtimeRequest(signedPayload: string): Promise { + const decodedRequest: DecodedRealtimeRequestBody = await this.verifyJWT(signedPayload, this.decodedRealtimeRequestBodyValidator, this.extractSignedDate); + if (this.environment === Environment.PRODUCTION && this.appAppleId !== decodedRequest.appAppleId) { + throw new VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER) + } + if (this.environment !== decodedRequest.environment) { + throw new VerificationException(VerificationStatus.INVALID_ENVIRONMENT) + } + return decodedRequest + } + protected async verifyJWT(jwt: string, validator: Validator, signedDateExtractor: (decodedJWT: T) => Date): Promise { let certificateChain; let decodedJWT diff --git a/models/AlternateProduct.ts b/models/AlternateProduct.ts new file mode 100644 index 0000000..484a096 --- /dev/null +++ b/models/AlternateProduct.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +/** + * A switch-plan message and product ID you provide in a real-time response to your Get Retention Message endpoint. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/alternateproduct alternateProduct} + */ +export interface AlternateProduct { + + /** + * The message identifier of the text to display in the switch-plan retention message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messageidentifier messageIdentifier} + **/ + messageIdentifier?: string + + /** + * The product identifier of the subscription the retention message suggests for your customer to switch to. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/productid productId} + **/ + productId?: string +} diff --git a/models/DecodedRealtimeRequestBody.ts b/models/DecodedRealtimeRequestBody.ts new file mode 100644 index 0000000..87d8375 --- /dev/null +++ b/models/DecodedRealtimeRequestBody.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { DecodedSignedData } from "./DecodedSignedData" +import { Environment, EnvironmentValidator } from "./Environment" +import { Validator } from "./Validator" + +/** + * The decoded request body the App Store sends to your server to request a real-time retention message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/decodedrealtimerequestbody DecodedRealtimeRequestBody} + */ +export interface DecodedRealtimeRequestBody extends DecodedSignedData { + + /** + * The original transaction identifier of the customer's subscription. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/originaltransactionid originalTransactionId} + **/ + originalTransactionId: string + + /** + * The unique identifier of the app in the App Store. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/appappleid appAppleId} + **/ + appAppleId: number + + /** + * The unique identifier of the auto-renewable subscription. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/productid productId} + **/ + productId: string + + /** + * The device's locale. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/locale locale} + **/ + userLocale: string + + /** + * A UUID the App Store server creates to uniquely identify each request. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/requestidentifier requestIdentifier} + **/ + requestIdentifier: string + + /** + * The UNIX time, in milliseconds, that the App Store signed the JSON Web Signature (JWS) data. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/signeddate signedDate} + **/ + signedDate: number + + /** + * The server environment, either sandbox or production. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/environment environment} + **/ + environment: Environment | string +} + + +export class DecodedRealtimeRequestBodyValidator implements Validator { + static readonly environmentValidator = new EnvironmentValidator() + validate(obj: any): obj is DecodedRealtimeRequestBody { + if (!(typeof obj['originalTransactionId'] === "string" || obj['originalTransactionId'] instanceof String)) { + return false + } + if (!(typeof obj['appAppleId'] === "number")) { + return false + } + if (!(typeof obj['productId'] === "string" || obj['productId'] instanceof String)) { + return false + } + if (!(typeof obj['userLocale'] === "string" || obj['userLocale'] instanceof String)) { + return false + } + if (!(typeof obj['requestIdentifier'] === "string" || obj['requestIdentifier'] instanceof String)) { + return false + } + if (!(typeof obj['signedDate'] === "number")) { + return false + } + if (!(DecodedRealtimeRequestBodyValidator.environmentValidator.validate(obj['environment']))) { + return false + } + return true + } +} diff --git a/models/DefaultConfigurationRequest.ts b/models/DefaultConfigurationRequest.ts new file mode 100644 index 0000000..6a3f392 --- /dev/null +++ b/models/DefaultConfigurationRequest.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +/** + * The request body that contains the default configuration information. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/defaultconfigurationrequest DefaultConfigurationRequest} + */ +export interface DefaultConfigurationRequest { + + /** + * The message identifier of the message to configure as a default message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messageidentifier messageIdentifier} + **/ + messageIdentifier?: string +} diff --git a/models/GetImageListResponse.ts b/models/GetImageListResponse.ts new file mode 100644 index 0000000..2f32cce --- /dev/null +++ b/models/GetImageListResponse.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { GetImageListResponseItem, GetImageListResponseItemValidator } from "./GetImageListResponseItem" +import { Validator } from "./Validator" + +/** + * A response that contains status information for all images. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/getimagelistresponse GetImageListResponse} + */ +export interface GetImageListResponse { + + /** + * An array of all image identifiers and their image state. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/getimagelistresponseitem GetImageListResponseItem} + **/ + imageIdentifiers?: GetImageListResponseItem[] +} + +export class GetImageListResponseValidator implements Validator { + static readonly getImageListResponseItemValidator = new GetImageListResponseItemValidator() + validate(obj: any): obj is GetImageListResponse { + if (typeof obj['imageIdentifiers'] !== 'undefined') { + if (!Array.isArray(obj['imageIdentifiers'])) { + return false + } + for (const imageIdentifier of obj['imageIdentifiers']) { + if (!(GetImageListResponseValidator.getImageListResponseItemValidator.validate(imageIdentifier))) { + return false + } + } + } + return true + } +} diff --git a/models/GetImageListResponseItem.ts b/models/GetImageListResponseItem.ts new file mode 100644 index 0000000..0527cec --- /dev/null +++ b/models/GetImageListResponseItem.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { ImageState, ImageStateValidator } from "./ImageState" +import { Validator } from "./Validator" + +/** + * An image identifier and state information for an image. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/getimagelistresponseitem GetImageListResponseItem} + */ +export interface GetImageListResponseItem { + + /** + * The identifier of the image. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/imageidentifier imageIdentifier} + **/ + imageIdentifier?: string + + /** + * The current state of the image. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/imagestate imageState} + **/ + imageState?: ImageState | string +} + +export class GetImageListResponseItemValidator implements Validator { + static readonly imageStateValidator = new ImageStateValidator() + validate(obj: any): obj is GetImageListResponseItem { + if ((typeof obj['imageIdentifier'] !== 'undefined') && !(typeof obj['imageIdentifier'] === "string" || obj['imageIdentifier'] instanceof String)) { + return false + } + if ((typeof obj['imageState'] !== 'undefined') && !(GetImageListResponseItemValidator.imageStateValidator.validate(obj['imageState']))) { + return false + } + return true + } +} diff --git a/models/GetMessageListResponse.ts b/models/GetMessageListResponse.ts new file mode 100644 index 0000000..fb30e10 --- /dev/null +++ b/models/GetMessageListResponse.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { GetMessageListResponseItem, GetMessageListResponseItemValidator } from "./GetMessageListResponseItem" +import { Validator } from "./Validator" + +/** + * A response that contains status information for all messages. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponse GetMessageListResponse} + */ +export interface GetMessageListResponse { + + /** + * An array of all message identifiers and their message state. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponseitem messageIdentifiers} + **/ + messageIdentifiers?: GetMessageListResponseItem[] +} + +export class GetMessageListResponseValidator implements Validator { + static readonly getMessageListResponseItemValidator = new GetMessageListResponseItemValidator() + validate(obj: any): obj is GetMessageListResponse { + if (typeof obj['messageIdentifiers'] !== 'undefined') { + if (!Array.isArray(obj['messageIdentifiers'])) { + return false + } + for (const messageIdentifier of obj['messageIdentifiers']) { + if (!(GetMessageListResponseValidator.getMessageListResponseItemValidator.validate(messageIdentifier))) { + return false + } + } + } + return true + } +} diff --git a/models/GetMessageListResponseItem.ts b/models/GetMessageListResponseItem.ts new file mode 100644 index 0000000..bf75ce7 --- /dev/null +++ b/models/GetMessageListResponseItem.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { MessageState, MessageStateValidator } from "./MessageState" +import { Validator } from "./Validator" + +/** + * A message identifier and status information for a message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponseitem GetMessageListResponseItem} + */ +export interface GetMessageListResponseItem { + + /** + * The identifier of the message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messageidentifier messageIdentifier} + **/ + messageIdentifier?: string + + /** + * The current state of the message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messagestate messageState} + **/ + messageState?: MessageState | string +} + +export class GetMessageListResponseItemValidator implements Validator { + static readonly messageStateValidator = new MessageStateValidator() + validate(obj: any): obj is GetMessageListResponseItem { + if ((typeof obj['messageIdentifier'] !== 'undefined') && !(typeof obj['messageIdentifier'] === "string" || obj['messageIdentifier'] instanceof String)) { + return false + } + if ((typeof obj['messageState'] !== 'undefined') && !(GetMessageListResponseItemValidator.messageStateValidator.validate(obj['messageState']))) { + return false + } + return true + } +} diff --git a/models/ImageState.ts b/models/ImageState.ts new file mode 100644 index 0000000..4e84bc2 --- /dev/null +++ b/models/ImageState.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { StringValidator } from "./Validator"; + +/** + * The approval state of an image. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/imagestate imageState} + */ +export enum ImageState { + PENDING_REVIEW = "PENDING_REVIEW", + APPROVED = "APPROVED", + REJECTED = "REJECTED", +} + +export class ImageStateValidator extends StringValidator {} diff --git a/models/Message.ts b/models/Message.ts new file mode 100644 index 0000000..fec3af7 --- /dev/null +++ b/models/Message.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +/** + * A message identifier you provide in a real-time response to your Get Retention Message endpoint. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/message message} + */ +export interface Message { + + /** + * The identifier of the message to display to the customer. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messageidentifier messageIdentifier} + **/ + messageIdentifier?: string +} diff --git a/models/MessageState.ts b/models/MessageState.ts new file mode 100644 index 0000000..ca2acb0 --- /dev/null +++ b/models/MessageState.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { StringValidator } from "./Validator"; + +/** + * The approval state of a message. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messagestate messageState} + */ +export enum MessageState { + PENDING_REVIEW = "PENDING_REVIEW", + APPROVED = "APPROVED", + REJECTED = "REJECTED", +} + +export class MessageStateValidator extends StringValidator {} diff --git a/models/PromotionalOffer.ts b/models/PromotionalOffer.ts new file mode 100644 index 0000000..fe068bc --- /dev/null +++ b/models/PromotionalOffer.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { PromotionalOfferSignatureV1 } from "./PromotionalOfferSignatureV1" + +/** + * A promotional offer and message you provide in a real-time response to your Get Retention Message endpoint. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/promotionaloffer promotionalOffer} + */ +export interface PromotionalOffer { + + /** + * The identifier of the message to display to the customer, along with the promotional offer. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/messageidentifier messageIdentifier} + **/ + messageIdentifier?: string + + /** + * The promotional offer signature in V2 format. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/promotionaloffersignaturev2 promotionalOfferSignatureV2} + **/ + promotionalOfferSignatureV2?: string + + /** + * The promotional offer signature in V1 format. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/promotionaloffersignaturev1 promotionalOfferSignatureV1} + **/ + promotionalOfferSignatureV1?: PromotionalOfferSignatureV1 +} diff --git a/models/PromotionalOfferSignatureV1.ts b/models/PromotionalOfferSignatureV1.ts new file mode 100644 index 0000000..87f9d40 --- /dev/null +++ b/models/PromotionalOfferSignatureV1.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +/** + * The promotional offer signature you generate using an earlier signature version. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/promotionaloffersignaturev1 promotionalOfferSignatureV1} + */ +export interface PromotionalOfferSignatureV1 { + + /** + * The Base64-encoded cryptographic signature you generate using the offer parameters. + **/ + encodedSignature: string + + /** + * The subscription's product identifier. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/productid productId} + **/ + productId: string + + /** + * A one-time-use UUID antireplay value you generate. + * + * **Note: Use lowercase.** + **/ + nonce: string + + /** + * The UNIX time, in milliseconds, when you generate the signature. + **/ + timestamp: number + + /** + * A string that identifies the private key you use to generate the signature. + * + * @return keyId + **/ + keyId: string + + /** + * The subscription offer identifier that you set up in App Store Connect. + **/ + offerIdentifier: string + + /** + * A UUID that you provide to associate with the transaction if the customer accepts the promotional offer. + * + * **Note: Use lowercase.** + **/ + appAccountToken?: string +} diff --git a/models/RealtimeRequestBody.ts b/models/RealtimeRequestBody.ts new file mode 100644 index 0000000..8393ed8 --- /dev/null +++ b/models/RealtimeRequestBody.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { Validator } from "./Validator" + +/** + * The request body the App Store server sends to your Get Retention Message endpoint. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/realtimerequestbody RealtimeRequestBody} + */ +export interface RealtimeRequestBody { + + /** + * The payload in JSON Web Signature (JWS) format, signed by the App Store. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/signedpayload signedPayload} + **/ + signedPayload?: string +} + + +export class RealtimeRequestBodyValidator implements Validator { + validate(obj: any): obj is RealtimeRequestBody { + if ((typeof obj['signedPayload'] !== 'undefined') && !(typeof obj['signedPayload'] === "string" || obj['signedPayload'] instanceof String)) { + return false + } + return true + } +} diff --git a/models/RealtimeResponseBody.ts b/models/RealtimeResponseBody.ts new file mode 100644 index 0000000..6248de9 --- /dev/null +++ b/models/RealtimeResponseBody.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { AlternateProduct } from "./AlternateProduct" +import { Message } from "./Message" +import { PromotionalOffer } from "./PromotionalOffer" + +/** + * A response you provide to choose, in real time, a retention message the system displays to the customer. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/realtimeresponsebody RealtimeResponseBody} + */ +export interface RealtimeResponseBody { + + /** + * A retention message that's text-based and can include an optional image. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/message message} + **/ + message?: Message + + /** + * A retention message with a switch-plan option. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/alternateproduct alternateProduct} + **/ + alternateProduct?: AlternateProduct + + /** + * A retention message that includes a promotional offer. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/promotionaloffer promotionalOffer} + **/ + promotionalOffer?: PromotionalOffer +} diff --git a/models/UploadMessageImage.ts b/models/UploadMessageImage.ts new file mode 100644 index 0000000..62b04f5 --- /dev/null +++ b/models/UploadMessageImage.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +/** + * The definition of an image with its alternative text. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage UploadMessageImage} + */ +export interface UploadMessageImage { + + /** + * The unique identifier of an image. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/imageidentifier imageIdentifier} + **/ + imageIdentifier: string + + /** + * The alternative text you provide for the corresponding image. + * + * **Maximum length: 150 characters** + * + * {@link https://developer.apple.com/documentation/retentionmessaging/alttext altText} + **/ + altText: string +} diff --git a/models/UploadMessageRequestBody.ts b/models/UploadMessageRequestBody.ts new file mode 100644 index 0000000..e247022 --- /dev/null +++ b/models/UploadMessageRequestBody.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { UploadMessageImage } from "./UploadMessageImage" + +/** + * The request body for uploading a message, which includes the message text and an optional image reference. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/uploadmessagerequestbody UploadMessageRequestBody} + */ +export interface UploadMessageRequestBody { + + /** + * The header text of the retention message that the system displays to customers. + * + * **Maximum length: 66 characters** + * + * {@link https://developer.apple.com/documentation/retentionmessaging/header header} + **/ + header: string + + /** + * The body text of the retention message that the system displays to customers. + * + * **Maximum length: 144 characters** + * + * {@link https://developer.apple.com/documentation/retentionmessaging/body body} + **/ + body: string + + /** + * The optional image identifier and its alternative text to appear as part of a text-based message with an image. + * + * {@link https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage UploadMessageImage} + **/ + image?: UploadMessageImage +} diff --git a/tests/resources/models/decodedRealtimeRequest.json b/tests/resources/models/decodedRealtimeRequest.json new file mode 100644 index 0000000..e966f47 --- /dev/null +++ b/tests/resources/models/decodedRealtimeRequest.json @@ -0,0 +1,9 @@ +{ + "originalTransactionId": "99371282", + "appAppleId": 531412, + "productId": "com.example.product", + "userLocale": "en-US", + "requestIdentifier": "3db5c98d-8acf-4e29-831e-8e1f82f9f6e9", + "environment": "LocalTesting", + "signedDate": 1698148900000 +} \ No newline at end of file diff --git a/tests/resources/models/getImageListResponse.json b/tests/resources/models/getImageListResponse.json new file mode 100644 index 0000000..652d2e9 --- /dev/null +++ b/tests/resources/models/getImageListResponse.json @@ -0,0 +1,8 @@ +{ + "imageIdentifiers": [ + { + "imageIdentifier": "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", + "imageState": "APPROVED" + } + ] +} diff --git a/tests/resources/models/getMessageListResponse.json b/tests/resources/models/getMessageListResponse.json new file mode 100644 index 0000000..4ccd8fe --- /dev/null +++ b/tests/resources/models/getMessageListResponse.json @@ -0,0 +1,8 @@ +{ + "messageIdentifiers": [ + { + "messageIdentifier": "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", + "messageState": "APPROVED" + } + ] +} diff --git a/tests/unit-tests/DecodedRealtimeRequestBody.test.ts b/tests/unit-tests/DecodedRealtimeRequestBody.test.ts new file mode 100644 index 0000000..028ed5a --- /dev/null +++ b/tests/unit-tests/DecodedRealtimeRequestBody.test.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { Environment } from "../../models/Environment"; +import { createSignedDataFromJson, getDefaultSignedPayloadVerifier } from "../util"; + +describe('DecodedRealtimeRequestBody', () => { + it('should decode a realtime request', async () => { + const signedRealtimeRequest = createSignedDataFromJson("tests/resources/models/decodedRealtimeRequest.json") + + const request = await getDefaultSignedPayloadVerifier().verifyAndDecodeRealtimeRequest(signedRealtimeRequest) + + expect("99371282").toBe(request.originalTransactionId) + expect(531412).toBe(request.appAppleId) + expect("com.example.product").toBe(request.productId) + expect("en-US").toBe(request.userLocale) + expect("3db5c98d-8acf-4e29-831e-8e1f82f9f6e9").toBe(request.requestIdentifier) + expect(Environment.LOCAL_TESTING).toBe(request.environment) + expect(1698148900000).toBe(request.signedDate) + }) +}) diff --git a/tests/unit-tests/RealtimeResponseBody.test.ts b/tests/unit-tests/RealtimeResponseBody.test.ts new file mode 100644 index 0000000..ff99eff --- /dev/null +++ b/tests/unit-tests/RealtimeResponseBody.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { RealtimeResponseBody } from "../../models/RealtimeResponseBody"; +import { Message } from "../../models/Message"; +import { AlternateProduct } from "../../models/AlternateProduct"; +import { PromotionalOffer } from "../../models/PromotionalOffer"; +import { PromotionalOfferSignatureV1 } from "../../models/PromotionalOfferSignatureV1"; + +describe('RealtimeResponseBody', () => { + it('should serialize RealtimeResponseBody with Message', () => { + // Create a RealtimeResponseBody with a Message + const messageId = "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890" + const message: Message = { + messageIdentifier: messageId + } + const responseBody: RealtimeResponseBody = { + message: message + } + + // Serialize to JSON + const json = JSON.stringify(responseBody) + const jsonObj = JSON.parse(json) + + // Validate JSON structure + expect(jsonObj).toHaveProperty("message") + expect(jsonObj.message).toHaveProperty("messageIdentifier") + expect(jsonObj.message.messageIdentifier).toBe("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890") + expect(jsonObj).not.toHaveProperty("alternateProduct") + expect(jsonObj).not.toHaveProperty("promotionalOffer") + + // Deserialize back + const deserialized: RealtimeResponseBody = JSON.parse(json) + + // Verify + expect(deserialized.message).toBeDefined() + expect(deserialized.message?.messageIdentifier).toBe(messageId) + expect(deserialized.alternateProduct).toBeUndefined() + expect(deserialized.promotionalOffer).toBeUndefined() + }) + + it('should serialize RealtimeResponseBody with AlternateProduct', () => { + // Create a RealtimeResponseBody with an AlternateProduct + const messageId = "b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901" + const productId = "com.example.alternate.product" + const alternateProduct: AlternateProduct = { + messageIdentifier: messageId, + productId: productId + } + const responseBody: RealtimeResponseBody = { + alternateProduct: alternateProduct + } + + // Serialize to JSON + const json = JSON.stringify(responseBody) + const jsonObj = JSON.parse(json) + + // Validate JSON structure + expect(jsonObj).toHaveProperty("alternateProduct") + expect(jsonObj.alternateProduct).toHaveProperty("messageIdentifier") + expect(jsonObj.alternateProduct).toHaveProperty("productId") + expect(jsonObj.alternateProduct.messageIdentifier).toBe("b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901") + expect(jsonObj.alternateProduct.productId).toBe("com.example.alternate.product") + expect(jsonObj).not.toHaveProperty("message") + expect(jsonObj).not.toHaveProperty("promotionalOffer") + + // Deserialize back + const deserialized: RealtimeResponseBody = JSON.parse(json) + + // Verify + expect(deserialized.message).toBeUndefined() + expect(deserialized.alternateProduct).toBeDefined() + expect(deserialized.alternateProduct?.messageIdentifier).toBe(messageId) + expect(deserialized.alternateProduct?.productId).toBe(productId) + expect(deserialized.promotionalOffer).toBeUndefined() + }) + + it('should serialize RealtimeResponseBody with PromotionalOffer V2', () => { + // Create a RealtimeResponseBody with a PromotionalOffer (V2 signature) + const messageId = "c3d4e5f6-a789-0123-c3d4-e5f6a7890123" + const signatureV2 = "signature2" + const promotionalOffer: PromotionalOffer = { + messageIdentifier: messageId, + promotionalOfferSignatureV2: signatureV2 + } + const responseBody: RealtimeResponseBody = { + promotionalOffer: promotionalOffer + } + + // Serialize to JSON + const json = JSON.stringify(responseBody) + const jsonObj = JSON.parse(json) + + // Validate JSON structure + expect(jsonObj).toHaveProperty("promotionalOffer") + expect(jsonObj.promotionalOffer).toHaveProperty("messageIdentifier") + expect(jsonObj.promotionalOffer).toHaveProperty("promotionalOfferSignatureV2") + expect(jsonObj.promotionalOffer.messageIdentifier).toBe("c3d4e5f6-a789-0123-c3d4-e5f6a7890123") + expect(jsonObj.promotionalOffer.promotionalOfferSignatureV2).toBe("signature2") + expect(jsonObj.promotionalOffer).not.toHaveProperty("promotionalOfferSignatureV1") + expect(jsonObj).not.toHaveProperty("message") + expect(jsonObj).not.toHaveProperty("alternateProduct") + + // Deserialize back + const deserialized: RealtimeResponseBody = JSON.parse(json) + + // Verify + expect(deserialized.message).toBeUndefined() + expect(deserialized.alternateProduct).toBeUndefined() + expect(deserialized.promotionalOffer).toBeDefined() + expect(deserialized.promotionalOffer?.messageIdentifier).toBe(messageId) + expect(deserialized.promotionalOffer?.promotionalOfferSignatureV2).toBe(signatureV2) + expect(deserialized.promotionalOffer?.promotionalOfferSignatureV1).toBeUndefined() + }) + + it('should serialize RealtimeResponseBody with PromotionalOffer V1', () => { + // Create a RealtimeResponseBody with a PromotionalOffer (V1 signature) + const messageId = "d4e5f6a7-8901-2345-d4e5-f6a789012345" + const nonce = "e5f6a789-0123-4567-e5f6-a78901234567" + const appAccountToken = "f6a78901-2345-6789-f6a7-890123456789" + const signatureV1: PromotionalOfferSignatureV1 = { + encodedSignature: "base64encodedSignature", + productId: "com.example.product", + nonce: nonce, + timestamp: 1698148900000, + keyId: "keyId123", + offerIdentifier: "offer123", + appAccountToken: appAccountToken + } + + const promotionalOffer: PromotionalOffer = { + messageIdentifier: messageId, + promotionalOfferSignatureV1: signatureV1 + } + const responseBody: RealtimeResponseBody = { + promotionalOffer: promotionalOffer + } + + // Serialize to JSON + const json = JSON.stringify(responseBody) + const jsonObj = JSON.parse(json) + + // Validate JSON structure + expect(jsonObj).toHaveProperty("promotionalOffer") + expect(jsonObj.promotionalOffer).toHaveProperty("messageIdentifier") + expect(jsonObj.promotionalOffer).toHaveProperty("promotionalOfferSignatureV1") + expect(jsonObj.promotionalOffer.messageIdentifier).toBe("d4e5f6a7-8901-2345-d4e5-f6a789012345") + + const v1Node = jsonObj.promotionalOffer.promotionalOfferSignatureV1 + expect(v1Node).toHaveProperty("encodedSignature") + expect(v1Node).toHaveProperty("productId") + expect(v1Node).toHaveProperty("nonce") + expect(v1Node).toHaveProperty("timestamp") + expect(v1Node).toHaveProperty("keyId") + expect(v1Node).toHaveProperty("offerIdentifier") + expect(v1Node).toHaveProperty("appAccountToken") + expect(v1Node.encodedSignature).toBe("base64encodedSignature") + expect(v1Node.productId).toBe("com.example.product") + expect(v1Node.nonce).toBe("e5f6a789-0123-4567-e5f6-a78901234567") + expect(v1Node.timestamp).toBe(1698148900000) + expect(v1Node.keyId).toBe("keyId123") + expect(v1Node.offerIdentifier).toBe("offer123") + expect(v1Node.appAccountToken).toBe("f6a78901-2345-6789-f6a7-890123456789") + + expect(jsonObj.promotionalOffer).not.toHaveProperty("promotionalOfferSignatureV2") + expect(jsonObj).not.toHaveProperty("message") + expect(jsonObj).not.toHaveProperty("alternateProduct") + + // Deserialize back + const deserialized: RealtimeResponseBody = JSON.parse(json) + + // Verify + expect(deserialized.message).toBeUndefined() + expect(deserialized.alternateProduct).toBeUndefined() + expect(deserialized.promotionalOffer).toBeDefined() + expect(deserialized.promotionalOffer?.messageIdentifier).toBe(messageId) + expect(deserialized.promotionalOffer?.promotionalOfferSignatureV2).toBeUndefined() + expect(deserialized.promotionalOffer?.promotionalOfferSignatureV1).toBeDefined() + + const deserializedV1 = deserialized.promotionalOffer?.promotionalOfferSignatureV1 + expect(deserializedV1?.productId).toBe("com.example.product") + expect(deserializedV1?.offerIdentifier).toBe("offer123") + expect(deserializedV1?.nonce).toBe(nonce) + expect(deserializedV1?.timestamp).toBe(1698148900000) + expect(deserializedV1?.keyId).toBe("keyId123") + expect(deserializedV1?.appAccountToken).toBe(appAccountToken) + expect(deserializedV1?.encodedSignature).toBe("base64encodedSignature") + }) + + it('should serialize RealtimeResponseBody with correct field names', () => { + // Test that JSON serialization uses correct field names + const messageId = "12345678-1234-1234-1234-123456789012" + const message: Message = { + messageIdentifier: messageId + } + const responseBody: RealtimeResponseBody = { + message: message + } + + const json = JSON.stringify(responseBody) + + // Verify JSON contains correct field names + expect(json).toContain('"message"') + expect(json).toContain('"messageIdentifier"') + expect(json).toContain('"12345678-1234-1234-1234-123456789012"') + }) +}) diff --git a/tests/unit-tests/api_client.test.ts b/tests/unit-tests/api_client.test.ts index 6c693d2..bbb1b44 100644 --- a/tests/unit-tests/api_client.test.ts +++ b/tests/unit-tests/api_client.test.ts @@ -22,7 +22,7 @@ import { Response } from "node-fetch"; import jsonwebtoken = require('jsonwebtoken'); -type callbackType = (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => void +type callbackType = (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => void class AppStoreServerAPIClientForTest extends AppStoreServerAPIClient { @@ -36,8 +36,17 @@ class AppStoreServerAPIClientForTest extends AppStoreServerAPIClient { this.body = body this.statusCode = statusCode } - protected async makeFetchRequest(path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }): Promise { - expect(headers['Content-Type']).toBe(typeof stringBody !== 'undefined' ? 'application/json' : undefined) + protected async makeFetchRequest(path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }): Promise { + // Check Content-Type based on body type + if (requestBody instanceof Buffer) { + expect(headers['Content-Type']).toBe('image/png') + } else if (requestBody !== undefined) { + expect(typeof requestBody).toBe('string') + expect(headers['Content-Type']).toBe('application/json') + } else { + expect(headers['Content-Type']).toBeUndefined() + } + expect('application/json').toBe(headers['Accept']) expect(headers['Authorization']).toMatch(/^Bearer .+/) const token = headers['Authorization'].substring(7) @@ -46,7 +55,7 @@ class AppStoreServerAPIClientForTest extends AppStoreServerAPIClient { expect(decodedToken['aud']).toBe('appstoreconnect-v1') expect(decodedToken['iss']).toBe('issuerId') expect(headers['User-Agent']).toMatch(/^app-store-server-library\/node\/.+/) - this.callback(path, parsedQueryParameters, method, stringBody, headers) + this.callback(path, parsedQueryParameters, method, requestBody, headers) return Promise.resolve(new Response(this.body, { status: this.statusCode })) @@ -70,13 +79,14 @@ function getSigningKey(): string { describe('The api client ', () => { it('calls extendRenewalDateForAllActiveSubscribers', async () => { - const client = getClientWithBody("tests/resources/models/extendRenewalDateForAllActiveSubscribersResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/extendRenewalDateForAllActiveSubscribersResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("POST").toBe(method) expect("/inApps/v1/subscriptions/extend/mass").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - - expect(stringBody).toBeTruthy() - const body = JSON.parse(stringBody!) + + expect(requestBody).toBeTruthy() + expect(typeof requestBody).toBe('string') + const body = JSON.parse(requestBody as string) expect(45).toBe(body.extendByDays) expect(1).toBe(body.extendReasonCode) expect("fdf964a4-233b-486c-aac1-97d8d52688ac").toBe(body.requestIdentifier) @@ -99,13 +109,14 @@ describe('The api client ', () => { }) it('calls extendSubscriptionRenewalDate', async () => { - const client = getClientWithBody("tests/resources/models/extendSubscriptionRenewalDateResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/extendSubscriptionRenewalDateResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("PUT").toBe(method) expect("/inApps/v1/subscriptions/extend/4124214").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeTruthy() - const body = JSON.parse(stringBody!) + expect(requestBody).toBeTruthy() + expect(typeof requestBody).toBe('string') + const body = JSON.parse(requestBody as string) expect(45).toBe(body.extendByDays) expect(1).toBe(body.extendReasonCode) expect("fdf964a4-233b-486c-aac1-97d8d52688ac").toBe(body.requestIdentifier) @@ -127,11 +138,11 @@ describe('The api client ', () => { }) it('calls getAllSubscriptionStatuses', async () => { - const client = getClientWithBody("tests/resources/models/getAllSubscriptionStatusesResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/getAllSubscriptionStatusesResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/subscriptions/4321").toBe(path) expect(["2", "1"]).toStrictEqual(parsedQueryParameters.getAll("status")) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const statusResponse = await client.getAllSubscriptionStatuses("4321", [Status.EXPIRED, Status.ACTIVE]); @@ -175,11 +186,11 @@ describe('The api client ', () => { }) it('calls getRefundHistory', async () => { - const client = getClientWithBody("tests/resources/models/getRefundHistoryResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/getRefundHistoryResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v2/refund/lookup/555555").toBe(path) expect("revision_input").toBe(parsedQueryParameters.get("revision")) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const refundHistoryResponse = await client.getRefundHistory("555555", "revision_input"); @@ -191,11 +202,11 @@ describe('The api client ', () => { }) it('calls getStatusOfSubscriptionRenewalDateExtensions', async () => { - const client = getClientWithBody("tests/resources/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/subscriptions/extend/mass/20fba8a0-2b80-4a7d-a17f-85c1854727f8/com.example.product").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const massExtendRenewalDateStatusResponse = await client.getStatusOfSubscriptionRenewalDateExtensions("com.example.product", "20fba8a0-2b80-4a7d-a17f-85c1854727f8"); @@ -209,11 +220,11 @@ describe('The api client ', () => { }) it('calls getTestNotificationStatus', async () => { - const client = getClientWithBody("tests/resources/models/getTestNotificationStatusResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/getTestNotificationStatusResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/notifications/test/8cd2974c-f905-492a-bf9a-b2f47c791d19").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const checkTestNotificationResponse = await client.getTestNotificationStatus("8cd2974c-f905-492a-bf9a-b2f47c791d19"); @@ -234,12 +245,13 @@ describe('The api client ', () => { }) it('calls getNotificationHistoryResponse', async () => { - const client = getClientWithBody("tests/resources/models/getNotificationHistoryResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/getNotificationHistoryResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("POST").toBe(method) expect("/inApps/v1/notifications/history").toBe(path) expect("a036bc0e-52b8-4bee-82fc-8c24cb6715d6").toBe(parsedQueryParameters.get("paginationToken")) - expect(stringBody).toBeTruthy() - const body = JSON.parse(stringBody!) + expect(requestBody).toBeTruthy() + expect(typeof requestBody).toBe('string') + const body = JSON.parse(requestBody as string) expect(1698148900000).toBe(body.startDate) expect(1698148950000).toBe(body.endDate) expect("SUBSCRIBED").toBe(body.notificationType) @@ -290,7 +302,7 @@ describe('The api client ', () => { }) it('calls getTransactionHistory V1', async () => { - const client = getClientWithBody("tests/resources/models/transactionHistoryResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/transactionHistoryResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/history/1234").toBe(path) expect("revision_input").toBe(parsedQueryParameters.get("revision")) @@ -302,7 +314,7 @@ describe('The api client ', () => { expect(["sub_group_id", "sub_group_id_2"]).toStrictEqual(parsedQueryParameters.getAll("subscriptionGroupIdentifier")) expect("FAMILY_SHARED").toBe(parsedQueryParameters.get("inAppOwnershipType")) expect("false").toBe(parsedQueryParameters.get("revoked")) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const request: TransactionHistoryRequest = { @@ -328,7 +340,7 @@ describe('The api client ', () => { }) it('calls getTransactionHistory V2', async () => { - const client = getClientWithBody("tests/resources/models/transactionHistoryResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/transactionHistoryResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v2/history/1234").toBe(path) expect("revision_input").toBe(parsedQueryParameters.get("revision")) @@ -340,7 +352,7 @@ describe('The api client ', () => { expect(["sub_group_id", "sub_group_id_2"]).toStrictEqual(parsedQueryParameters.getAll("subscriptionGroupIdentifier")) expect("FAMILY_SHARED").toBe(parsedQueryParameters.get("inAppOwnershipType")) expect("false").toBe(parsedQueryParameters.get("revoked")) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const request: TransactionHistoryRequest = { @@ -366,11 +378,11 @@ describe('The api client ', () => { }) it('calls getTransactionInfo', async () => { - const client = getClientWithBody("tests/resources/models/transactionInfoResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/transactionInfoResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/transactions/1234").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const transactionInfoResponse = await client.getTransactionInfo("1234"); @@ -380,11 +392,11 @@ describe('The api client ', () => { }) it('calls lookUpOrderId', async () => { - const client = getClientWithBody("tests/resources/models/lookupOrderIdResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/lookupOrderIdResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/lookup/W002182").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const orderLookupResponse = await client.lookUpOrderId("W002182"); @@ -395,11 +407,11 @@ describe('The api client ', () => { }) it('calls requestTestNotification', async () => { - const client = getClientWithBody("tests/resources/models/requestTestNotificationResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/requestTestNotificationResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("POST").toBe(method) expect("/inApps/v1/notifications/test").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }); const sendTestNotificationResponse = await client.requestTestNotification(); @@ -409,13 +421,14 @@ describe('The api client ', () => { }) it('calls sendConsumptionData', async () => { - const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("PUT").toBe(method) expect("/inApps/v1/transactions/consumption/49571273").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - - expect(stringBody).toBeTruthy() - const body = JSON.parse(stringBody!) + + expect(requestBody).toBeTruthy() + expect(typeof requestBody).toBe('string') + const body = JSON.parse(requestBody as string) expect(body.customerConsented).toBe(true) expect(1).toBe(body.consumptionStatus) expect(2).toBe(body.platform) @@ -449,11 +462,11 @@ describe('The api client ', () => { }) it('calls getTransactionInfo but receives a general internal error', async () => { - const client = getClientWithBody("tests/resources/models/apiException.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/apiException.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/transactions/1234").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }, 500); try { @@ -468,11 +481,11 @@ describe('The api client ', () => { }) it('calls getTransactionInfo but receives a rate limit exceeded error', async () => { - const client = getClientWithBody("tests/resources/models/apiTooManyRequestsException.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/apiTooManyRequestsException.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/transactions/1234").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }, 429); try { @@ -487,11 +500,11 @@ describe('The api client ', () => { }) it('calls getTransactionInfo but receives an unknown/new error code', async () => { - const client = getClientWithBody("tests/resources/models/apiUnknownError.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/apiUnknownError.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("GET").toBe(method) expect("/inApps/v1/transactions/1234").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeUndefined() + expect(requestBody).toBeUndefined() }, 400); try { @@ -506,7 +519,7 @@ describe('The api client ', () => { }) it('calls getTransactionHistory but receives an unknown environment', async () => { - const client = getClientWithBody("tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { }); const request: TransactionHistoryRequest = { @@ -525,7 +538,7 @@ describe('The api client ', () => { }) it('calls getTransactionHistory but receives a malformed appAppleId', async () => { - const client = getClientWithBody("tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { }); const request: TransactionHistoryRequest = { @@ -559,13 +572,14 @@ describe('The api client ', () => { }) it('calls setAppAccountToken', async () => { - const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("PUT").toBe(method) expect("/inApps/v1/transactions/49571273/appAccountToken").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeTruthy() - const body = JSON.parse(stringBody!) + expect(requestBody).toBeTruthy() + expect(typeof requestBody).toBe('string') + const body = JSON.parse(requestBody as string) expect("7389a31a-fb6d-4569-a2a6-db7d85d84813").toBe(body.appAccountToken) }); @@ -577,11 +591,11 @@ describe('The api client ', () => { }) it('calls setAppAccountToken but receives an invalid UUID error', async () => { - const client = getClientWithBody("tests/resources/models/invalidAppAccountTokenUUIDError.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/invalidAppAccountTokenUUIDError.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("PUT").toBe(method) expect("/inApps/v1/transactions/49571273/appAccountToken").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeTruthy() + expect(requestBody).toBeTruthy() }, 400); try { @@ -599,11 +613,11 @@ describe('The api client ', () => { }) it('calls setAppAccountToken but receives family transaction not supported error', async () => { - const client = getClientWithBody("tests/resources/models/familyTransactionNotSupportedError.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/familyTransactionNotSupportedError.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("PUT").toBe(method) expect("/inApps/v1/transactions/1234/appAccountToken").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeTruthy() + expect(requestBody).toBeTruthy() }, 400); try { @@ -621,11 +635,11 @@ describe('The api client ', () => { }) it('calls setAppAccountToken but transactionId not originalTransactionId error', async () => { - const client = getClientWithBody("tests/resources/models/transactionIdNotOriginalTransactionId.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, stringBody: string | undefined, headers: { [key: string]: string; }) => { + const client = getClientWithBody("tests/resources/models/transactionIdNotOriginalTransactionId.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("PUT").toBe(method) expect("/inApps/v1/transactions/1234/appAccountToken").toBe(path) expect(parsedQueryParameters.entries.length).toBe(0) - expect(stringBody).toBeTruthy() + expect(requestBody).toBeTruthy() }, 400); try { @@ -641,4 +655,150 @@ describe('The api client ', () => { expect(error.errorMessage).toBe("Invalid request. The transaction ID provided is not an original transaction ID.") } }) + + it('calls uploadImage', async () => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("PUT").toBe(method) + expect("/inApps/v1/messaging/image/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeTruthy() + expect(requestBody).toBeInstanceOf(Buffer) + expect(Buffer.from([1, 2, 3])).toEqual(requestBody) + }); + + await client.uploadImage("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", Buffer.from([1, 2, 3])) + }) + + it('calls deleteImage', async () => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("DELETE").toBe(method) + expect("/inApps/v1/messaging/image/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeUndefined() + }); + + await client.deleteImage("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890") + }) + + it('calls getImageList', async () => { + const client = getClientWithBody("tests/resources/models/getImageListResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("GET").toBe(method) + expect("/inApps/v1/messaging/image/list").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeUndefined() + }); + + const response = await client.getImageList() + + expect(response).toBeTruthy() + expect(response.imageIdentifiers).toBeDefined() + expect(response.imageIdentifiers?.length).toBe(1) + expect(response.imageIdentifiers?.[0].imageIdentifier).toBe("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890") + expect(response.imageIdentifiers?.[0].imageState).toBe("APPROVED") + }) + + it('calls uploadMessage', async () => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("PUT").toBe(method) + expect("/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeTruthy() + expect(typeof requestBody).toBe('string') + + const body = JSON.parse(requestBody as string) + expect("Header text").toBe(body.header) + expect("Body text").toBe(body.body) + expect(body.image).toBeUndefined() + }); + + const uploadMessageRequestBody = { + header: "Header text", + body: "Body text" + } + await client.uploadMessage("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", uploadMessageRequestBody) + }) + + it('calls uploadMessage with image', async () => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("PUT").toBe(method) + expect("/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeTruthy() + expect(typeof requestBody).toBe('string') + + const body = JSON.parse(requestBody as string) + expect("Header text").toBe(body.header) + expect("Body text").toBe(body.body) + expect(body.image).toBeDefined() + expect("b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901").toBe(body.image.imageIdentifier) + expect("Alt text").toBe(body.image.altText) + }); + + const uploadMessageRequestBody = { + header: "Header text", + body: "Body text", + image: { + imageIdentifier: "b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901", + altText: "Alt text" + } + } + await client.uploadMessage("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", uploadMessageRequestBody) + }) + + it('calls deleteMessage', async () => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("DELETE").toBe(method) + expect("/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeUndefined() + }); + + await client.deleteMessage("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890") + }) + + it('calls getMessageList', async () => { + const client = getClientWithBody("tests/resources/models/getMessageListResponse.json", (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("GET").toBe(method) + expect("/inApps/v1/messaging/message/list").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeUndefined() + }); + + const response = await client.getMessageList() + + expect(response).toBeTruthy() + expect(response.messageIdentifiers).toBeDefined() + expect(response.messageIdentifiers?.length).toBe(1) + expect(response.messageIdentifiers?.[0].messageIdentifier).toBe("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890") + expect(response.messageIdentifiers?.[0].messageState).toBe("APPROVED") + }) + + it('calls configureDefaultMessage', async () => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("PUT").toBe(method) + expect("/inApps/v1/messaging/default/com.example.product/en-US").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeTruthy() + expect(typeof requestBody).toBe('string') + + const body = JSON.parse(requestBody as string) + expect("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890").toBe(body.messageIdentifier) + }); + + const defaultConfigurationRequest = { + messageIdentifier: "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890" + } + await client.configureDefaultMessage("com.example.product", "en-US", defaultConfigurationRequest) + }) + + it('calls deleteDefaultMessage', async () => { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect("DELETE").toBe(method) + expect("/inApps/v1/messaging/default/com.example.product/en-US").toBe(path) + expect(parsedQueryParameters.entries.length).toBe(0) + expect(requestBody).toBeUndefined() + }); + + await client.deleteDefaultMessage("com.example.product", "en-US") + }) }) \ No newline at end of file