mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06dbc19a5a | ||
|
|
84fadc4eac |
No files matched your search
@@ -685,10 +685,6 @@
|
||||
"markdownDescription": "Additional information about your app that can help during the review process.\n\n**Do not include demo account details.**",
|
||||
"minLength": 2,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"attachment": {
|
||||
"type": "string",
|
||||
"description": "Relative path (from project root) to a file shared with the App Store review team. The file is uploaded as an App Store review attachment and only re-uploaded when its content changes."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -804,6 +800,65 @@
|
||||
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
|
||||
}
|
||||
},
|
||||
"ApplePricing": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "App pricing configuration. NOTE: Apple migrated to base-territory pricing in 2023. EAS metadata currently uses the legacy `appPriceTier` API exposed by @expo/apple-utils.",
|
||||
"properties": {
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"description": "App Store price tier id (e.g. \"0\" for free, \"1\" for tier 1).",
|
||||
"defaultSnippets": [
|
||||
{ "label": "Free", "body": "0" },
|
||||
{ "label": "Tier 1", "body": "1" }
|
||||
]
|
||||
},
|
||||
"schedule": {
|
||||
"type": "array",
|
||||
"description": "Future scheduled price changes. Each entry switches the app to the given tier on `startDate`. Currently parsed but not pushed (the underlying appPriceSchedules endpoint is not yet wrapped by @expo/apple-utils).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["startDate", "tier"],
|
||||
"properties": {
|
||||
"startDate": {
|
||||
"type": "string",
|
||||
"description": "ISO-8601 date the new price tier takes effect.",
|
||||
"format": "date-time"
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"description": "App Store price tier id (e.g. \"0\" for free)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAvailability": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Territory availability configuration. Use ISO-3166 alpha-3 codes (e.g. \"USA\", \"GBR\", \"JPN\") which match App Store Connect Territory ids.",
|
||||
"properties": {
|
||||
"territories": {
|
||||
"description": "Territories the app is available in. Use the literal string \"all\" to make the app available worldwide.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "ISO-3166 alpha-3 territory code (e.g. \"USA\")."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["all"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAgeRatingOverride": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
@@ -1038,6 +1093,14 @@
|
||||
"appClip": {
|
||||
"$ref": "#/definitions/apple/AppleAppClip",
|
||||
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
|
||||
},
|
||||
"pricing": {
|
||||
"$ref": "#/definitions/apple/ApplePricing",
|
||||
"description": "App pricing configuration (price tier + scheduled changes)."
|
||||
},
|
||||
"availability": {
|
||||
"$ref": "#/definitions/apple/AppleAvailability",
|
||||
"description": "Territory availability configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,35 +216,3 @@ describe('getAppClip', () => {
|
||||
expect(reader.getAppClipLocalizedInfo('de-DE')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReviewAttachment', () => {
|
||||
it('returns null when review is not configured', () => {
|
||||
const reader = new AppleConfigReader({});
|
||||
expect(reader.getReviewAttachment()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when review has no attachment', () => {
|
||||
const reader = new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
},
|
||||
});
|
||||
expect(reader.getReviewAttachment()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the configured attachment path', () => {
|
||||
const reader = new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/demo.pdf',
|
||||
},
|
||||
});
|
||||
expect(reader.getReviewAttachment()).toBe('store/apple/review-attachment/demo.pdf');
|
||||
});
|
||||
});
|
||||
@@ -345,30 +345,6 @@ describe('setReviewDetails', () => {
|
||||
demoRequired: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an existing review attachment when re-setting review details', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setReviewDetails(nameOnlyReviewDetails);
|
||||
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
|
||||
writer.setReviewDetails(nameAndDemoReviewDetails);
|
||||
expect(writer.schema.review?.attachment).toBe('store/apple/review-attachment/demo.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setReviewAttachment', () => {
|
||||
it('initializes a review block with the attachment path', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
|
||||
expect(writer.schema.review?.attachment).toBe('store/apple/review-attachment/demo.pdf');
|
||||
});
|
||||
|
||||
it('clears the attachment when null is passed', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setReviewDetails(nameOnlyReviewDetails);
|
||||
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
|
||||
writer.setReviewAttachment(null);
|
||||
expect(writer.schema.review?.attachment).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppClipDefaultExperience', () => {
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
AppleAppClip,
|
||||
AppleAppClipDefaultExperience,
|
||||
AppleAppClipLocalizedInfo,
|
||||
AppleAvailability,
|
||||
AppleMetadata,
|
||||
ApplePreviews,
|
||||
ApplePricing,
|
||||
AppleScreenshots,
|
||||
} from '../types';
|
||||
|
||||
@@ -224,14 +226,10 @@ export class AppleConfigReader {
|
||||
demoAccountPassword: review.demoPassword,
|
||||
demoAccountRequired: review.demoRequired,
|
||||
notes: review.notes,
|
||||
// TODO: add attachment
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the configured review attachment path (relative to the project root), or null if not set. */
|
||||
public getReviewAttachment(): string | null {
|
||||
return this.schema.review?.attachment ?? null;
|
||||
}
|
||||
|
||||
/** Get screenshots configuration for a specific locale */
|
||||
public getScreenshots(locale: string): AppleScreenshots | null {
|
||||
return this.schema.info?.[locale]?.screenshots ?? null;
|
||||
@@ -261,4 +259,14 @@ export class AppleConfigReader {
|
||||
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
|
||||
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
|
||||
}
|
||||
|
||||
/** Get the pricing block, or null if not configured. */
|
||||
public getPricing(): ApplePricing | null {
|
||||
return this.schema.pricing ?? null;
|
||||
}
|
||||
|
||||
/** Get the availability block, or null if not configured. */
|
||||
public getAvailability(): AppleAvailability | null {
|
||||
return this.schema.availability ?? null;
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
AppleAppClipDefaultExperience,
|
||||
AppleAppClipLocalizedInfo,
|
||||
AppleAppClipReviewDetail,
|
||||
AppleAvailability,
|
||||
AppleMetadata,
|
||||
ApplePreviews,
|
||||
ApplePricing,
|
||||
AppleScreenshots,
|
||||
} from '../types';
|
||||
|
||||
@@ -190,7 +192,6 @@ export class AppleConfigWriter {
|
||||
}
|
||||
|
||||
public setReviewDetails(attributes: AttributesOf<AppStoreReviewDetail>): void {
|
||||
const existingAttachment = this.schema.review?.attachment;
|
||||
this.schema.review = {
|
||||
firstName: attributes.contactFirstName ?? '',
|
||||
lastName: attributes.contactLastName ?? '',
|
||||
@@ -200,25 +201,10 @@ export class AppleConfigWriter {
|
||||
demoPassword: optional(attributes.demoAccountPassword),
|
||||
demoRequired: optional(attributes.demoAccountRequired),
|
||||
notes: optional(attributes.notes),
|
||||
attachment: existingAttachment,
|
||||
// TODO: add attachment
|
||||
};
|
||||
}
|
||||
|
||||
/** Set the review attachment path (relative to project root) on the review block. */
|
||||
public setReviewAttachment(attachmentPath: string | null): void {
|
||||
this.schema.review = this.schema.review ?? {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
};
|
||||
if (attachmentPath) {
|
||||
this.schema.review.attachment = attachmentPath;
|
||||
} else {
|
||||
delete this.schema.review.attachment;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set screenshots for a specific locale */
|
||||
public setScreenshots(locale: string, screenshots: AppleScreenshots): void {
|
||||
this.schema.info = this.schema.info ?? {};
|
||||
@@ -257,6 +243,29 @@ export class AppleConfigWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the pricing configuration (price tier + scheduled changes). */
|
||||
public setPricing(pricing: ApplePricing | null): void {
|
||||
if (!pricing || (pricing.tier == null && !pricing.schedule?.length)) {
|
||||
delete this.schema.pricing;
|
||||
return;
|
||||
}
|
||||
this.schema.pricing = {
|
||||
tier: pricing.tier,
|
||||
schedule: pricing.schedule && pricing.schedule.length > 0 ? pricing.schedule : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Set the territory availability configuration. */
|
||||
public setAvailability(availability: AppleAvailability | null): void {
|
||||
if (!availability || availability.territories == null) {
|
||||
delete this.schema.availability;
|
||||
return;
|
||||
}
|
||||
this.schema.availability = {
|
||||
territories: availability.territories,
|
||||
};
|
||||
}
|
||||
|
||||
/** Set per-locale App Clip info (subtitle + header image). */
|
||||
public setAppClipLocalizedInfo(locale: string, info: AppleAppClipLocalizedInfo): void {
|
||||
this.schema.appClip = this.schema.appClip ?? {};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AppInfoData } from './tasks/app-info';
|
||||
import type { AppReviewData } from './tasks/app-review-detail';
|
||||
import type { AppVersionData } from './tasks/app-version';
|
||||
import type { PreviewsData } from './tasks/previews';
|
||||
import type { PricingData } from './tasks/pricing';
|
||||
import type { ScreenshotsData } from './tasks/screenshots';
|
||||
|
||||
/**
|
||||
@@ -18,7 +19,8 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
|
||||
AppReviewData &
|
||||
ScreenshotsData &
|
||||
PreviewsData &
|
||||
AppClipData;
|
||||
AppClipData &
|
||||
PricingData;
|
||||
|
||||
/**
|
||||
* The unprepared partial apple data, used within the `prepareAsync` tasks.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AppStoreReviewAttachment, AppStoreReviewDetail, AppStoreVersion } from '@expo/apple-utils';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import nock from 'nock';
|
||||
|
||||
import { requestContext } from './fixtures/requestContext';
|
||||
@@ -11,10 +9,6 @@ import { AppReviewDetailTask } from '../app-review-detail';
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../config/writer');
|
||||
|
||||
function md5(value: Buffer | string): string {
|
||||
return crypto.createHash('md5').update(value).digest('hex');
|
||||
}
|
||||
|
||||
describe(AppReviewDetailTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('loads review details from app version', async () => {
|
||||
@@ -57,78 +51,6 @@ describe(AppReviewDetailTask, () => {
|
||||
|
||||
expect(writer.setReviewDetails).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('records the review attachment placeholder path when one exists', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const attachment = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
|
||||
fileName: 'demo-instructions.pdf',
|
||||
fileSize: 1024,
|
||||
sourceFileChecksum: 'abc123',
|
||||
uploaded: true,
|
||||
} as any);
|
||||
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
|
||||
appStoreReviewAttachments: [attachment],
|
||||
} as any);
|
||||
|
||||
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
|
||||
await new AppReviewDetailTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { reviewDetail, projectDir: '/test/project' } as any,
|
||||
});
|
||||
|
||||
expect(writer.setReviewDetails).toBeCalledWith(reviewDetail.attributes);
|
||||
expect(writer.setReviewAttachment).toBeCalledWith(
|
||||
'store/apple/review-attachment/demo-instructions.pdf'
|
||||
);
|
||||
|
||||
existsSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs an info message when attachment exists remotely but not on disk', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const attachment = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
|
||||
fileName: 'demo-instructions.pdf',
|
||||
fileSize: 1024,
|
||||
sourceFileChecksum: 'abc123',
|
||||
uploaded: true,
|
||||
} as any);
|
||||
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
|
||||
appStoreReviewAttachments: [attachment],
|
||||
} as any);
|
||||
|
||||
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
|
||||
|
||||
await new AppReviewDetailTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { reviewDetail, projectDir: '/test/project' } as any,
|
||||
});
|
||||
|
||||
// Should still record the path in config for round-trip support
|
||||
expect(writer.setReviewAttachment).toBeCalledWith(
|
||||
'store/apple/review-attachment/demo-instructions.pdf'
|
||||
);
|
||||
// Should NOT create directories on disk (no placeholder file)
|
||||
expect(mkdirSpy).not.toBeCalled();
|
||||
|
||||
existsSyncSpy.mockRestore();
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not call setReviewAttachment when there is no attachment', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
|
||||
appStoreReviewAttachments: [],
|
||||
} as any);
|
||||
|
||||
await new AppReviewDetailTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { reviewDetail, projectDir: '/test/project' } as any,
|
||||
});
|
||||
|
||||
expect(writer.setReviewAttachment).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsync', () => {
|
||||
@@ -219,182 +141,5 @@ describe(AppReviewDetailTask, () => {
|
||||
expect(createReviewDetailScope.isDone()).toBeTruthy();
|
||||
expect(updateReviewDetailScope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('attachment sync', () => {
|
||||
const fileBytes = Buffer.from('file-bytes');
|
||||
const fileChecksum = md5(fileBytes);
|
||||
|
||||
let existsSyncSpy: jest.SpyInstance;
|
||||
let readFileSyncSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
readFileSyncSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue(fileBytes as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
existsSyncSpy.mockRestore();
|
||||
readFileSyncSpy.mockRestore();
|
||||
jest.restoreAllMocks();
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('uploads a new attachment when none exists', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
|
||||
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
|
||||
|
||||
const reviewDetail = new AppStoreReviewDetail(
|
||||
requestContext,
|
||||
'APP_STORE_REVIEW_DETAILS_1',
|
||||
{ appStoreReviewAttachments: [] } as any
|
||||
);
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
|
||||
.mockResolvedValue({} as any);
|
||||
|
||||
await new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/demo.pdf',
|
||||
},
|
||||
}),
|
||||
context: {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
reviewDetail,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(uploadSpy).toBeCalledTimes(1);
|
||||
expect(uploadSpy).toBeCalledWith(
|
||||
expect.stringContaining('store/apple/review-attachment/demo.pdf')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips upload when checksum matches existing attachment', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
|
||||
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
|
||||
|
||||
const existing = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
|
||||
fileName: 'demo.pdf',
|
||||
fileSize: fileBytes.length,
|
||||
sourceFileChecksum: fileChecksum,
|
||||
uploaded: true,
|
||||
} as any);
|
||||
const reviewDetail = new AppStoreReviewDetail(
|
||||
requestContext,
|
||||
'APP_STORE_REVIEW_DETAILS_1',
|
||||
{ appStoreReviewAttachments: [existing] } as any
|
||||
);
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
|
||||
.mockResolvedValue({} as any);
|
||||
const deleteSpy = jest.spyOn(existing, 'deleteAsync').mockResolvedValue(undefined);
|
||||
|
||||
await new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/demo.pdf',
|
||||
},
|
||||
}),
|
||||
context: {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
reviewDetail,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(uploadSpy).not.toBeCalled();
|
||||
expect(deleteSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('replaces stale attachment when checksum differs', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
|
||||
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
|
||||
|
||||
const existing = new AppStoreReviewAttachment(requestContext, 'ATTACH_OLD', {
|
||||
fileName: 'old.pdf',
|
||||
fileSize: 5,
|
||||
sourceFileChecksum: 'different-checksum',
|
||||
uploaded: true,
|
||||
} as any);
|
||||
const reviewDetail = new AppStoreReviewDetail(
|
||||
requestContext,
|
||||
'APP_STORE_REVIEW_DETAILS_1',
|
||||
{ appStoreReviewAttachments: [existing] } as any
|
||||
);
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
|
||||
.mockResolvedValue({} as any);
|
||||
const deleteSpy = jest.spyOn(existing, 'deleteAsync').mockResolvedValue(undefined);
|
||||
|
||||
await new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/demo.pdf',
|
||||
},
|
||||
}),
|
||||
context: {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
reviewDetail,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(deleteSpy).toBeCalledTimes(1);
|
||||
expect(uploadSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns and skips when local attachment file is missing', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
|
||||
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
|
||||
|
||||
existsSyncSpy.mockReturnValue(false);
|
||||
|
||||
const reviewDetail = new AppStoreReviewDetail(
|
||||
requestContext,
|
||||
'APP_STORE_REVIEW_DETAILS_1',
|
||||
{ appStoreReviewAttachments: [] } as any
|
||||
);
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
|
||||
.mockResolvedValue({} as any);
|
||||
|
||||
await new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/missing.pdf',
|
||||
},
|
||||
}),
|
||||
context: {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
reviewDetail,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(uploadSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { App, AppPrice, AppPriceTier, Territory } from '@expo/apple-utils';
|
||||
|
||||
import { requestContext } from './fixtures/requestContext';
|
||||
import { AppleConfigReader } from '../../config/reader';
|
||||
import { AppleConfigWriter } from '../../config/writer';
|
||||
import { PartialAppleData } from '../../data';
|
||||
import { PricingTask } from '../pricing';
|
||||
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../config/writer');
|
||||
|
||||
function makePrice(id: string, startDate: string, tierId: string): AppPrice {
|
||||
const tier = new AppPriceTier(requestContext, tierId, {});
|
||||
return new AppPrice(requestContext, id, {
|
||||
startDate,
|
||||
priceTier: tier,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function makeTerritory(code: string, currency = 'USD'): Territory {
|
||||
return new Territory(requestContext, code, { currency });
|
||||
}
|
||||
|
||||
function makeApp(id = 'stub-id'): App {
|
||||
return new App(requestContext, id, {} as any);
|
||||
}
|
||||
|
||||
describe(PricingTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('populates appPrices and availableTerritories from App.infoAsync', async () => {
|
||||
const app = makeApp();
|
||||
const fetched = new App(requestContext, 'stub-id', {
|
||||
prices: [makePrice('p1', '2024-01-01T00:00:00Z', '0')],
|
||||
availableTerritories: [makeTerritory('USA'), makeTerritory('GBR', 'GBP')],
|
||||
} as any);
|
||||
const spy = jest.spyOn(App, 'infoAsync').mockResolvedValue(fetched);
|
||||
|
||||
const context: PartialAppleData = { app, projectDir: '/test' };
|
||||
await new PricingTask().prepareAsync({ context });
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
app.context,
|
||||
expect.objectContaining({
|
||||
id: 'stub-id',
|
||||
query: expect.objectContaining({
|
||||
includes: expect.arrayContaining(['prices', 'availableTerritories']),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(context.appPrices).toHaveLength(1);
|
||||
expect(context.availableTerritories).toHaveLength(2);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('initializes empty arrays when the API call fails', async () => {
|
||||
const spy = jest
|
||||
.spyOn(App, 'infoAsync')
|
||||
.mockRejectedValue(new Error('legacy pricing endpoint not available'));
|
||||
|
||||
const context: PartialAppleData = { app: makeApp(), projectDir: '/test' };
|
||||
await new PricingTask().prepareAsync({ context });
|
||||
|
||||
expect(context.appPrices).toEqual([]);
|
||||
expect(context.availableTerritories).toEqual([]);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
it('clears pricing and availability when the app has neither', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new PricingTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
expect(writer.setPricing).toHaveBeenCalledWith(null);
|
||||
expect(writer.setAvailability).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('writes the current price tier (free)', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new PricingTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
appPrices: [makePrice('p1', '2020-01-01T00:00:00Z', '0')],
|
||||
availableTerritories: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setPricing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tier: '0', schedule: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('writes future price changes as schedule entries', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const future = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString();
|
||||
|
||||
await new PricingTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
appPrices: [makePrice('p1', '2020-01-01T00:00:00Z', '0'), makePrice('p2', future, '5')],
|
||||
availableTerritories: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setPricing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tier: '0',
|
||||
schedule: [{ startDate: future, tier: '5' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('writes territory codes', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new PricingTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
appPrices: [],
|
||||
availableTerritories: [makeTerritory('USA'), makeTerritory('GBR', 'GBP')],
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setAvailability).toHaveBeenCalledWith({ territories: ['USA', 'GBR'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsync', () => {
|
||||
it('skips when neither pricing nor availability is configured', async () => {
|
||||
const app = makeApp();
|
||||
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
|
||||
|
||||
await new PricingTask().uploadAsync({
|
||||
config: new AppleConfigReader({}),
|
||||
context: { app, appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pushes the configured price tier', async () => {
|
||||
const app = makeApp();
|
||||
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
|
||||
|
||||
await new PricingTask().uploadAsync({
|
||||
config: new AppleConfigReader({ pricing: { tier: '0' } }),
|
||||
context: { app, appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith({
|
||||
appPriceTier: '0',
|
||||
territories: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes a paid price tier', async () => {
|
||||
const app = makeApp();
|
||||
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
|
||||
|
||||
await new PricingTask().uploadAsync({
|
||||
config: new AppleConfigReader({ pricing: { tier: '5' } }),
|
||||
context: { app, appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith({ appPriceTier: '5', territories: undefined });
|
||||
});
|
||||
|
||||
it('warns and skips push for scheduled price changes (pending expo/third-party#147)', async () => {
|
||||
const app = makeApp();
|
||||
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await new PricingTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
pricing: {
|
||||
tier: '0',
|
||||
schedule: [{ startDate: '2099-01-01T00:00:00Z', tier: '5' }],
|
||||
},
|
||||
}),
|
||||
context: { app, appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
// Tier still gets pushed; schedule is logged as a warning.
|
||||
expect(updateSpy).toHaveBeenCalledWith({ appPriceTier: '0', territories: undefined });
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('pushes a single-territory availability list', async () => {
|
||||
const app = makeApp();
|
||||
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
|
||||
|
||||
await new PricingTask().uploadAsync({
|
||||
config: new AppleConfigReader({ availability: { territories: ['USA'] } }),
|
||||
context: { app, appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith({
|
||||
appPriceTier: undefined,
|
||||
territories: ['USA'],
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes multiple territories, normalized and deduplicated', async () => {
|
||||
const app = makeApp();
|
||||
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
|
||||
|
||||
await new PricingTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
availability: { territories: ['usa', 'GBR', 'usa', 'JPN'] },
|
||||
}),
|
||||
context: { app, appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith({
|
||||
appPriceTier: undefined,
|
||||
territories: ['USA', 'GBR', 'JPN'],
|
||||
});
|
||||
});
|
||||
|
||||
it('expands `all` to every supported territory', async () => {
|
||||
const app = makeApp();
|
||||
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
|
||||
const territorySpy = jest
|
||||
.spyOn(Territory, 'getAsync')
|
||||
.mockResolvedValue([
|
||||
makeTerritory('USA'),
|
||||
makeTerritory('GBR', 'GBP'),
|
||||
makeTerritory('JPN', 'JPY'),
|
||||
]);
|
||||
|
||||
await new PricingTask().uploadAsync({
|
||||
config: new AppleConfigReader({ availability: { territories: 'all' } }),
|
||||
context: { app, appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
expect(territorySpy).toHaveBeenCalled();
|
||||
expect(updateSpy).toHaveBeenCalledWith({
|
||||
appPriceTier: undefined,
|
||||
territories: ['USA', 'GBR', 'JPN'],
|
||||
});
|
||||
territorySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('combines pricing and availability into a single update call', async () => {
|
||||
const app = makeApp();
|
||||
const updateSpy = jest.spyOn(app, 'updateAsync').mockResolvedValue(app);
|
||||
|
||||
await new PricingTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
pricing: { tier: '0' },
|
||||
availability: { territories: ['USA', 'GBR'] },
|
||||
}),
|
||||
context: { app, appPrices: [], availableTerritories: [] } as any,
|
||||
});
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateSpy).toHaveBeenCalledWith({
|
||||
appPriceTier: '0',
|
||||
territories: ['USA', 'GBR'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,5 @@
|
||||
import { AppStoreReviewAttachment, AppStoreReviewDetail } from '@expo/apple-utils';
|
||||
import { AppStoreReviewDetail } from '@expo/apple-utils';
|
||||
import chalk from 'chalk';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { logAsync } from '../../utils/log';
|
||||
@@ -13,14 +10,6 @@ export type AppReviewData = {
|
||||
reviewDetail: AppStoreReviewDetail;
|
||||
};
|
||||
|
||||
/** Default directory (relative to project root) for downloaded review attachments. */
|
||||
const REVIEW_ATTACHMENT_DIR = path.join('store', 'apple', 'review-attachment');
|
||||
|
||||
function md5OfFile(absolutePath: string): string {
|
||||
const bytes = fs.readFileSync(absolutePath);
|
||||
return crypto.createHash('md5').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
/** Handle all contact, demo account, or instruction info that are required for the App Store review team. */
|
||||
export class AppReviewDetailTask extends AppleTask {
|
||||
public name = (): string => 'app review detail';
|
||||
@@ -33,41 +22,9 @@ export class AppReviewDetailTask extends AppleTask {
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
if (!context.reviewDetail) {
|
||||
return;
|
||||
if (context.reviewDetail) {
|
||||
config.setReviewDetails(context.reviewDetail.attributes);
|
||||
}
|
||||
|
||||
config.setReviewDetails(context.reviewDetail.attributes);
|
||||
|
||||
// ASC limitation — see expo/third-party#146. AppStoreReviewAttachment has no
|
||||
// download URL (contrast with AppScreenshot.imageAsset.templateUrl). The API
|
||||
// only exposes fileName, fileSize, sourceFileChecksum, uploadOperations, and
|
||||
// assetDeliveryState. We record the expected path in the config so that the
|
||||
// pull -> push round-trip preserves the attachment entry, but we cannot
|
||||
// download the actual file bytes.
|
||||
const attachments = context.reviewDetail.attributes.appStoreReviewAttachments ?? [];
|
||||
const attachment = attachments[0];
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = attachment.attributes.fileName || 'attachment';
|
||||
const relativePath = path.join(REVIEW_ATTACHMENT_DIR, fileName);
|
||||
const absolutePath = path.resolve(context.projectDir, relativePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
Log.log(
|
||||
chalk`{yellow Review attachment "${chalk.bold(
|
||||
fileName
|
||||
)}" exists in App Store Connect but cannot be downloaded.}\n` +
|
||||
chalk` App Store Connect does not expose a download URL for review attachments\n` +
|
||||
chalk` (unlike screenshots which have {dim imageAsset.templateUrl}). Only {dim fileName},\n` +
|
||||
chalk` {dim fileSize}, and {dim sourceFileChecksum} are available from the API.\n` +
|
||||
chalk` To keep the attachment in sync, place the file at: {bold ${relativePath}}`
|
||||
);
|
||||
}
|
||||
|
||||
config.setReviewAttachment(relativePath);
|
||||
}
|
||||
|
||||
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
|
||||
@@ -97,82 +54,10 @@ export class AppReviewDetailTask extends AppleTask {
|
||||
);
|
||||
}
|
||||
|
||||
// Capture existing attachments before the patch response overwrites them. The
|
||||
// PATCH response doesn't include the related `appStoreReviewAttachments`, so
|
||||
// we need to reuse the version we fetched in `prepareAsync` to make a
|
||||
// checksum-based skip decision below.
|
||||
const existingAttachments = context.reviewDetail.attributes.appStoreReviewAttachments ?? [];
|
||||
|
||||
context.reviewDetail = await logAsync(() => context.reviewDetail.updateAsync(reviewDetail), {
|
||||
pending: `Updating store review details for ${chalk.bold(versionString)}...`,
|
||||
success: `Updated store review details for ${chalk.bold(versionString)}`,
|
||||
failure: `Failed updating store review details for ${chalk.bold(versionString)}`,
|
||||
});
|
||||
|
||||
await this.syncAttachmentAsync({
|
||||
attachmentPath: config.getReviewAttachment(),
|
||||
reviewDetail: context.reviewDetail,
|
||||
existingAttachments,
|
||||
projectDir: context.projectDir,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the App Store review attachment.
|
||||
* - When no attachment is configured, do nothing (existing remote attachments are left alone).
|
||||
* - When the configured file matches an existing attachment by checksum, skip re-upload.
|
||||
* - Otherwise delete any existing attachment(s) and upload the new file.
|
||||
*/
|
||||
private async syncAttachmentAsync({
|
||||
attachmentPath,
|
||||
reviewDetail,
|
||||
existingAttachments,
|
||||
projectDir,
|
||||
}: {
|
||||
attachmentPath: string | null;
|
||||
reviewDetail: AppStoreReviewDetail;
|
||||
existingAttachments: AppStoreReviewAttachment[];
|
||||
projectDir: string;
|
||||
}): Promise<void> {
|
||||
if (!attachmentPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(projectDir, attachmentPath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
Log.warn(chalk`{yellow Review attachment not found: ${absolutePath}}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = path.basename(absolutePath);
|
||||
const localChecksum = md5OfFile(absolutePath);
|
||||
|
||||
const matching = existingAttachments.find(
|
||||
attachment =>
|
||||
attachment.attributes.uploaded &&
|
||||
attachment.attributes.sourceFileChecksum === localChecksum
|
||||
);
|
||||
|
||||
if (matching) {
|
||||
Log.log(chalk`{dim - Skipped review attachment ${fileName}, already up to date}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any stale attachments before uploading the new one. App Store
|
||||
// Connect generally allows a single review attachment per review detail,
|
||||
// so we replace whatever is currently there.
|
||||
for (const attachment of existingAttachments) {
|
||||
await logAsync(() => attachment.deleteAsync(), {
|
||||
pending: `Deleting review attachment ${chalk.bold(attachment.attributes.fileName)}...`,
|
||||
success: `Deleted review attachment ${chalk.bold(attachment.attributes.fileName)}`,
|
||||
failure: `Failed deleting review attachment ${chalk.bold(attachment.attributes.fileName)}`,
|
||||
});
|
||||
}
|
||||
|
||||
await logAsync(() => reviewDetail.uploadAttachmentAsync(absolutePath), {
|
||||
pending: `Uploading review attachment ${chalk.bold(fileName)}...`,
|
||||
success: `Uploaded review attachment ${chalk.bold(fileName)}`,
|
||||
failure: `Failed uploading review attachment ${chalk.bold(fileName)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { AppInfoTask } from './app-info';
|
||||
import { AppReviewDetailTask } from './app-review-detail';
|
||||
import { AppVersionOptions, AppVersionTask } from './app-version';
|
||||
import { PreviewsTask } from './previews';
|
||||
import { PricingTask } from './pricing';
|
||||
import { ScreenshotsTask } from './screenshots';
|
||||
import { AppleTask } from '../task';
|
||||
|
||||
@@ -23,5 +24,6 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
|
||||
new ScreenshotsTask(),
|
||||
new PreviewsTask(),
|
||||
new AppClipTask(),
|
||||
new PricingTask(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { App, AppPrice, Territory } from '@expo/apple-utils';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { logAsync } from '../../utils/log';
|
||||
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
|
||||
import { ApplePriceScheduleEntry, AppleTerritoryCode } from '../types';
|
||||
|
||||
/**
|
||||
* Sentinel territory list meaning "make the app available in every supported
|
||||
* App Store territory". Resolved at upload time against
|
||||
* `Territory.getAsync` so we don't drift if Apple adds new territories.
|
||||
*/
|
||||
export const AVAILABILITY_ALL = 'all' as const;
|
||||
|
||||
export type PricingData = {
|
||||
/**
|
||||
* Current `AppPrice` records for the app. Each record encodes a (startDate,
|
||||
* priceTier) pair — the active price tier is the most recent entry whose
|
||||
* startDate is in the past.
|
||||
*/
|
||||
appPrices: AppPrice[];
|
||||
/** Territories the app is currently available in. */
|
||||
availableTerritories: Territory[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Task for managing app pricing (price tier + scheduled changes) and
|
||||
* territory availability. Apple groups these together because both are set
|
||||
* via the legacy `App` PATCH endpoint that `@expo/apple-utils` exposes via
|
||||
* `App.updateAsync({ appPriceTier, territories })`.
|
||||
*
|
||||
* Newer App Store Connect APIs (`appPriceSchedules`, `appAvailabilities`)
|
||||
* supersede this for accounts on the post-2023 base-territory pricing model,
|
||||
* but those endpoints are not yet wrapped by `@expo/apple-utils`. The reader
|
||||
* accepts a `pricing.schedule` block that we can wire up once the new
|
||||
* helpers land.
|
||||
*
|
||||
* TODO(expo/third-party#147): Once `@expo/apple-utils` is bumped to include
|
||||
* the new pricing helpers, wire up schedule push:
|
||||
*
|
||||
* - Use `AppPriceSchedule.createAsync(context, { appId, baseTerritoryId, manualPrices })`
|
||||
* to push the full schedule. Each `manualPrices` entry takes
|
||||
* `{ appPricePointId, startDate?, endDate? }`.
|
||||
*
|
||||
* - IMPORTANT: `AppPriceSchedule.createAsync` REPLACES the entire prior
|
||||
* schedule. When pushing, the full schedule from config must be sent --
|
||||
* omitting entries will remove them from App Store Connect. There is no
|
||||
* diff/merge behavior.
|
||||
*
|
||||
* - Use `AppPricePoint.getForAppAsync(context, appId, { query })` to
|
||||
* resolve tier names to `appPricePointId` values, filterable by territory.
|
||||
*
|
||||
* - Use `App.getPriceScheduleAsync()` to read the current schedule (singular
|
||||
* relationship: `apps/{id}/appPriceSchedule`).
|
||||
*
|
||||
* - Territory availability is separate from pricing in the modern API --
|
||||
* it's managed via `TerritoryAvailability`, not via price schedules.
|
||||
* The legacy `App.updateAsync({ territories })` path used below still
|
||||
* works for setting availability.
|
||||
*/
|
||||
export class PricingTask extends AppleTask {
|
||||
public name = (): string => 'pricing and availability';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
context.appPrices = [];
|
||||
context.availableTerritories = [];
|
||||
|
||||
// Pull the current `prices` and `availableTerritories` relationships
|
||||
// onto the in-memory App object. We refetch via `infoAsync` because the
|
||||
// App used elsewhere in the flow is loaded without these includes.
|
||||
let appWithPricing: App;
|
||||
try {
|
||||
appWithPricing = await App.infoAsync(context.app.context, {
|
||||
id: context.app.id,
|
||||
query: {
|
||||
includes: ['prices', 'prices.priceTier', 'availableTerritories'],
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Pricing/availability are not always retrievable for every app state
|
||||
// (e.g. apps removed from sale, or accounts on the new pricing model
|
||||
// that 404 the legacy endpoints). Don't block the rest of the sync.
|
||||
Log.warn(
|
||||
chalk`{yellow Skipped pricing/availability prepare - failed to load: ${error?.message ?? error}}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.appPrices = appWithPricing.attributes.prices ?? [];
|
||||
context.availableTerritories = appWithPricing.attributes.availableTerritories ?? [];
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
// Pricing → schema.pricing
|
||||
const sortedPrices = [...(context.appPrices ?? [])].sort((a, b) =>
|
||||
(a.attributes.startDate ?? '').localeCompare(b.attributes.startDate ?? '')
|
||||
);
|
||||
|
||||
if (sortedPrices.length === 0) {
|
||||
config.setPricing(null);
|
||||
} else {
|
||||
// The "current" tier is the most recent entry whose startDate is in
|
||||
// the past (or the earliest entry if everything is in the future).
|
||||
const now = new Date().toISOString();
|
||||
const past = sortedPrices.filter(p => (p.attributes.startDate ?? '') <= now);
|
||||
const currentEntry = past.length > 0 ? past[past.length - 1] : sortedPrices[0];
|
||||
const future = sortedPrices.filter(p => (p.attributes.startDate ?? '') > now);
|
||||
|
||||
const schedule: ApplePriceScheduleEntry[] = future.map(p => ({
|
||||
startDate: p.attributes.startDate,
|
||||
tier: p.attributes.priceTier?.id ?? '',
|
||||
}));
|
||||
|
||||
config.setPricing({
|
||||
tier: currentEntry.attributes.priceTier?.id,
|
||||
schedule: schedule.length > 0 ? schedule : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Availability → schema.availability
|
||||
const territories = context.availableTerritories ?? [];
|
||||
if (territories.length === 0) {
|
||||
config.setAvailability(null);
|
||||
} else {
|
||||
config.setAvailability({
|
||||
territories: territories.map(t => t.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
|
||||
const pricing = config.getPricing();
|
||||
const availability = config.getAvailability();
|
||||
|
||||
if (!pricing && !availability) {
|
||||
Log.log(chalk`{dim - Skipped pricing and availability, not configured}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pricing?.schedule && pricing.schedule.length > 0) {
|
||||
// TODO(expo/third-party#147): Replace this warning with a call to
|
||||
// AppPriceSchedule.createAsync once @expo/apple-utils is bumped.
|
||||
// Remember: createAsync REPLACES the entire schedule, so the full
|
||||
// config must be sent (not just the diff).
|
||||
Log.warn(
|
||||
chalk`{yellow pricing.schedule is not yet pushed. Scheduled price changes require a newer @expo/apple-utils with AppPriceSchedule support (see expo/third-party#147). Only pricing.tier is applied.}`
|
||||
);
|
||||
}
|
||||
|
||||
// Territory availability is set via the legacy App.updateAsync({ territories })
|
||||
// endpoint below. The modern ASC API uses TerritoryAvailability as a separate
|
||||
// resource from pricing (not part of appPriceSchedules). The legacy path works
|
||||
// for both old and new pricing-model accounts.
|
||||
let resolvedTerritories: AppleTerritoryCode[] | undefined;
|
||||
if (availability?.territories) {
|
||||
resolvedTerritories = await resolveTerritoriesAsync({
|
||||
context,
|
||||
desired: availability.territories,
|
||||
});
|
||||
}
|
||||
|
||||
const appPriceTier = pricing?.tier;
|
||||
if (appPriceTier == null && resolvedTerritories == null) {
|
||||
// Nothing to do — both blocks were configured but neither set a value
|
||||
// we can act on (e.g. only `pricing.schedule` was provided).
|
||||
return;
|
||||
}
|
||||
|
||||
const summary: string[] = [];
|
||||
if (appPriceTier != null) {
|
||||
summary.push(`tier ${chalk.bold(appPriceTier)}`);
|
||||
}
|
||||
if (resolvedTerritories != null) {
|
||||
summary.push(`${chalk.bold(resolvedTerritories.length)} territories`);
|
||||
}
|
||||
|
||||
context.app = await logAsync(
|
||||
() =>
|
||||
context.app.updateAsync({
|
||||
appPriceTier,
|
||||
territories: resolvedTerritories,
|
||||
}),
|
||||
{
|
||||
pending: `Updating pricing and availability (${summary.join(', ')})...`,
|
||||
success: `Updated pricing and availability (${summary.join(', ')})`,
|
||||
failure: 'Failed to update pricing and availability',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a desired territory list (either an explicit code list or the
|
||||
* `'all'` sentinel) into an array of ISO territory codes the App Store
|
||||
* Connect API will accept.
|
||||
*/
|
||||
async function resolveTerritoriesAsync({
|
||||
context,
|
||||
desired,
|
||||
}: {
|
||||
context: { app: App };
|
||||
desired: AppleTerritoryCode[] | typeof AVAILABILITY_ALL;
|
||||
}): Promise<AppleTerritoryCode[]> {
|
||||
if (desired === AVAILABILITY_ALL) {
|
||||
const territories = await Territory.getAsync(context.app.context, {
|
||||
query: { limit: 200 },
|
||||
});
|
||||
return territories.map(t => t.id);
|
||||
}
|
||||
// Deduplicate, drop empties, and uppercase to match ASC's canonical form.
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const code of desired) {
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
const normalized = code.toUpperCase();
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -54,8 +54,65 @@ export interface AppleMetadata {
|
||||
review?: AppleReview;
|
||||
/** App Clip metadata. Only applies to apps that ship an App Clip target. */
|
||||
appClip?: AppleAppClip;
|
||||
/** Pricing configuration for the app (price tier, scheduled changes). */
|
||||
pricing?: ApplePricing;
|
||||
/** Territory availability configuration for the app. */
|
||||
availability?: AppleAvailability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing configuration for an app.
|
||||
*
|
||||
* NOTE: Apple migrated to "base territory" pricing in 2023, replacing the
|
||||
* legacy global price tier model. The App Store Connect API still exposes a
|
||||
* `priceTier` concept on the legacy `appPrices`/`appPriceTiers` resources,
|
||||
* which is what `@expo/apple-utils` currently exposes via
|
||||
* `App.updateAsync({ appPriceTier })`. Newer apps may need to use
|
||||
* `appPriceSchedules` + `appPricePoints` (which require selecting a base
|
||||
* territory and a price point id), but those endpoints are not yet wrapped by
|
||||
* `@expo/apple-utils`. We use the legacy field for now and document this in
|
||||
* the PR. The schema is intentionally forward-compatible.
|
||||
*/
|
||||
export interface ApplePricing {
|
||||
/**
|
||||
* App Store price tier (e.g. `'0'` for free, `'1'` for tier 1). When
|
||||
* unset on a brand new app, the App Store defaults to free (tier 0).
|
||||
*/
|
||||
tier?: string;
|
||||
/**
|
||||
* Future scheduled price changes. Each entry switches the app to the
|
||||
* given tier on `startDate`. The first entry that matches "now" or earlier
|
||||
* is treated as the current price. This block is currently parsed but not
|
||||
* pushed (the legacy `App.updateAsync` endpoint exposed by
|
||||
* `@expo/apple-utils` only sets the active tier).
|
||||
*/
|
||||
schedule?: ApplePriceScheduleEntry[];
|
||||
}
|
||||
|
||||
export interface ApplePriceScheduleEntry {
|
||||
/** ISO-8601 date the new price tier takes effect. */
|
||||
startDate: string;
|
||||
/** App Store price tier id (e.g. `'0'` for free). */
|
||||
tier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Territory availability configuration. Use ISO-3166 alpha-3 codes (e.g.
|
||||
* `"USA"`, `"GBR"`, `"JPN"`) — these match the App Store Connect Territory
|
||||
* resource ids.
|
||||
*/
|
||||
export interface AppleAvailability {
|
||||
/**
|
||||
* The territories the app is available in. Use the literal string `'all'`
|
||||
* to make the app available worldwide (every territory currently supported
|
||||
* by App Store Connect). Omitting this field leaves availability untouched.
|
||||
*/
|
||||
territories?: AppleTerritoryCode[] | 'all';
|
||||
}
|
||||
|
||||
/** ISO-3166 alpha-3 territory code (e.g. `"USA"`, `"GBR"`). */
|
||||
export type AppleTerritoryCode = string;
|
||||
|
||||
/** App Clip action enum values from App Store Connect API */
|
||||
export type AppleAppClipAction = `${AppClipAction}`;
|
||||
|
||||
@@ -128,10 +185,5 @@ export interface AppleReview {
|
||||
demoPassword?: string;
|
||||
demoRequired?: boolean;
|
||||
notes?: string;
|
||||
/**
|
||||
* Relative path (from project root) to a file shared with App Review.
|
||||
* This is uploaded as an `appStoreReviewAttachment` and re-uploaded only when the
|
||||
* file's checksum differs from the existing attachment.
|
||||
*/
|
||||
attachment?: string;
|
||||
// attachment?: string;
|
||||
}
|
||||
Reference in new issue
Block a user