Skip to content

Commit

Permalink
[eas-cli] Add metadata app store task runner (#1133)
Browse files Browse the repository at this point in the history
* [eas-cli] Add metadata app store task runner

* [eas-cli] Use expect error over ignore

Co-authored-by: Evan Bacon <baconbrix@gmail.com>

* [eas-cli] Remove unused ts directive

* [eas-cli] Add download task tests

Co-authored-by: Evan Bacon <baconbrix@gmail.com>
  • Loading branch information
byCedric and EvanBacon authored Jun 3, 2022
1 parent 1341bb1 commit 4c6226b
Show file tree
Hide file tree
Showing 24 changed files with 1,735 additions and 1 deletion.
18 changes: 18 additions & 0 deletions packages/eas-cli/src/metadata/apple/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { App } from '@expo/apple-utils';

import type { AgeRatingData } from './tasks/age-rating';
import type { AppInfoData } from './tasks/app-info';
import type { AppVersionData } from './tasks/app-version';

/**
* The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks.
* It contains references to each individual models, to either upload or download App Store data.
*/
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData;

/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
* It contains a reference to the app, each task should populate the necessary data.
* If an entity fails to prepare the data, individual tasks should raise errors about the missing data.
*/
export type PartialAppleData = { app: App } & Partial<Omit<AppleData, 'app'>>;
31 changes: 31 additions & 0 deletions packages/eas-cli/src/metadata/apple/task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { AppleConfigReader } from './config/reader';
import { AppleConfigWriter } from './config/writer';
import { AppleData, PartialAppleData } from './data';

export abstract class AppleTask {
/** Get a description from the task to use as section headings in the log */
abstract name(): string;

/** Prepare the data from the App Store to start syncing with the store configuration */
abstract prepareAsync(options: TaskPrepareOptions): Promise<void>;

/** Download all information from the App Store to generate the store configuration */
abstract downloadAsync(options: TaskDownloadOptions): Promise<void>;

/** Upload all information from the store configuration to the App Store */
abstract uploadAsync(options: TaskUploadOptions): Promise<void>;
}

export type TaskPrepareOptions = {
context: PartialAppleData;
};

export type TaskDownloadOptions = {
config: AppleConfigWriter;
context: AppleData;
};

export type TaskUploadOptions = {
config: AppleConfigReader;
context: AppleData;
};
8 changes: 8 additions & 0 deletions packages/eas-cli/src/metadata/apple/tasks/__mocks__/nock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getRequestClient } from '@expo/apple-utils';

export { default } from 'nock';

// Axios from apple-utils needs to be patched when we use Nock.
// Instead of the default adapter, we need to use `axios/lib/adapters/http`
// see: https://github.com/nock/nock#axios
getRequestClient().defaults.adapter = require('axios/lib/adapters/http');
111 changes: 111 additions & 0 deletions packages/eas-cli/src/metadata/apple/tasks/__tests__/age-rating.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { AgeRatingDeclaration, AppStoreVersion, Rating } from '@expo/apple-utils';
import nock from 'nock';

import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData } from '../../data';
import { AgeRatingTask } from '../age-rating';
import { requestContext } from './fixtures/requestContext';

jest.mock('../../../../ora');
jest.mock('../../config/writer');

describe(AgeRatingTask, () => {
describe('prepareAsync', () => {
it('aborts when version is not loaded', async () => {
const promise = new AgeRatingTask().prepareAsync({ context: {} as any });

await expect(promise).rejects.toThrow('not prepared');
});

it('loads age rating from app version', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
.reply(200, require('./fixtures/appStoreVersions/get-ageRatingDeclaration-200.json'));

const context: any = {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
};

await new AgeRatingTask().prepareAsync({ context });

expect(context.ageRating).toBeInstanceOf(AgeRatingDeclaration);
expect(scope.isDone()).toBeTruthy();
});
});

describe('downloadAsync', () => {
it('skips age rating when not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());

await new AgeRatingTask().downloadAsync({
config: writer,
context: { ageRating: undefined } as any,
});

expect(writer.setAgeRating).not.toBeCalled();
});

it('sets age rating when prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const ageRating = new AgeRatingDeclaration(requestContext, 'stub-id', {} as any);

await new AgeRatingTask().downloadAsync({
config: writer,
context: { ageRating } as any,
});

expect(writer.setAgeRating).toBeCalledWith(ageRating.attributes);
});
});

describe('uploadAsync', () => {
it('aborts when age rating is not loaded', async () => {
const promise = new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
});

await expect(promise).rejects.toThrow('rating not initialized');
});

it('skips updating age rating when not configured', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AgeRatingDeclaration.type}/stub-id`)
.reply(200, require('./fixtures/ageRatingDeclarations/patch-200.json'));

await new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({ advisory: undefined }),
context: {
ageRating: new AgeRatingDeclaration(requestContext, 'stub-id', {} as any),
} as AppleData,
});

expect(scope.isDone()).toBeFalsy();
nock.cleanAll();
});

it('updates age rating from config when configured', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AgeRatingDeclaration.type}/stub-id`)
.reply(200, require('./fixtures/ageRatingDeclarations/patch-200.json'));

const context = {
ageRating: new AgeRatingDeclaration(requestContext, 'stub-id', {} as any),
};

await new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({
advisory: {
// See fixture value for horrorOrFearThemes
horrorOrFearThemes: Rating.INFREQUENT_OR_MILD,
},
}),
context: context as AppleData,
});

expect(context.ageRating.id).not.toMatch('stub-id');
expect(scope.isDone()).toBeTruthy();
});
});
});
173 changes: 173 additions & 0 deletions packages/eas-cli/src/metadata/apple/tasks/__tests__/app-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { App, AppCategoryId, AppInfo, AppInfoLocalization } from '@expo/apple-utils';
import nock from 'nock';

import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData, PartialAppleData } from '../../data';
import { AppInfoTask } from '../app-info';
import { requestContext } from './fixtures/requestContext';

jest.mock('../../../../ora');
jest.mock('../../config/writer');

describe(AppInfoTask, () => {
describe('prepareAsync', () => {
it('loads editable app info and locales from app instance', async () => {
const scopeInfo = nock('https://api.appstoreconnect.apple.com')
.get(uri => uri.startsWith(`/v1/${App.type}/stub-id/${AppInfo.type}`)) // allow any query params
.reply(200, require('./fixtures/apps/get-appInfos-200.json'));

const scopeLocales = nock('https://api.appstoreconnect.apple.com')
.get(/\/v1\/appInfos\/.*\/appInfoLocalizations/) // allow any id from fixture
.reply(200, require('./fixtures/appInfos/get-appInfoLocalizations-200.json'));

const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
};

await new AppInfoTask().prepareAsync({ context });

expect(context.info).toBeInstanceOf(AppInfo);
expect(context.infoLocales).toBeInstanceOf(Array);
expect(scopeInfo.isDone()).toBeTruthy();
expect(scopeLocales.isDone()).toBeTruthy();
});
});

describe('downloadAsync', () => {
it('aborts when app info is not loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const promise = new AppInfoTask().downloadAsync({
config: writer,
context: { info: undefined, infoLocales: [] } as any,
});

await expect(promise).rejects.toThrow('info not initialized');
});

it('sets categories when info is loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const info = new AppInfo(requestContext, 'stub-id', {} as any);

await new AppInfoTask().downloadAsync({
config: writer,
context: { info, infoLocales: [] } as any,
});

expect(writer.setCategories).toBeCalledWith(info.attributes);
});

it('skips when no locales are loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());

await new AppInfoTask().downloadAsync({
config: writer,
context: {
info: new AppInfo(requestContext, 'stub-id', {} as any),
infoLocales: [],
} as any,
});

expect(writer.setInfoLocale).not.toBeCalled();
});

it('sets locales when loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const infoLocales = [
new AppInfoLocalization(requestContext, 'stub-id-1', {} as any),
new AppInfoLocalization(requestContext, 'stub-id-2', {} as any),
];

await new AppInfoTask().downloadAsync({
config: writer,
context: {
info: new AppInfo(requestContext, 'stub-id', {} as any),
infoLocales,
} as any,
});

expect(writer.setInfoLocale).toBeCalledWith(infoLocales[0].attributes);
expect(writer.setInfoLocale).toBeCalledWith(infoLocales[1].attributes);
});
});

describe('uploadAsync', () => {
it('aborts when app info is not loaded', async () => {
const promise = new AppInfoTask().uploadAsync({
config: new AppleConfigReader({}),
context: { info: undefined } as any,
});

await expect(promise).rejects.toThrow('info not initialized');
});

it('skips updating categories when not configured', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppInfo.type}/stub-id`)
.reply(200, require('./fixtures/appInfos/patch-200.json'));

await new AppInfoTask().uploadAsync({
config: new AppleConfigReader({ categories: undefined }),
context: {
info: new AppInfo(requestContext, 'stub-id', {} as any),
} as AppleData,
});

expect(scope.isDone()).toBeFalsy();
nock.cleanAll();
});

it('skips updating localized info when not configured', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppInfoLocalization.type}/stub-id`)
.reply(200, require('./fixtures/appInfos/patch-200.json'));

await new AppInfoTask().uploadAsync({
config: new AppleConfigReader({ info: undefined }),
context: {
info: new AppInfo(requestContext, 'stub-id', {} as any),
} as AppleData,
});

expect(scope.isDone()).toBeFalsy();
nock.cleanAll();
});

it('updates categories and localized info', async () => {
const updateCategoryScope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppInfo.type}/stub-id`)
.reply(200, require('./fixtures/appInfos/patch-200.json'));

const updateENInfoScope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppInfoLocalization.type}/APP_INFO_LOCALE_1`) // see fixture ID
.reply(200, require('./fixtures/appInfoLocalizations/patch-200.json'));

const updateNLInfoScope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppInfoLocalization.type}/APP_INFO_LOCALE_2`) // see fixture ID
.reply(200, require('./fixtures/appInfoLocalizations/patch-200.json'));

const fetchLocalizedInfoScope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppInfo.type}/APP_INFO_1/${AppInfoLocalization.type}`) // see fixture ID
.twice()
.reply(200, require('./fixtures/appInfos/get-appInfoLocalizations-200.json'));

await new AppInfoTask().uploadAsync({
config: new AppleConfigReader({
categories: [AppCategoryId.ENTERTAINMENT],
info: {
'en-US': { title: 'Hello' },
'nl-NL': { title: 'Hallo' },
},
}),
context: {
info: new AppInfo(requestContext, 'stub-id', {} as any),
} as AppleData,
});

expect(updateCategoryScope.isDone()).toBeTruthy();
expect(fetchLocalizedInfoScope.isDone()).toBeTruthy();
expect(updateENInfoScope.isDone()).toBeTruthy();
expect(updateNLInfoScope.isDone()).toBeTruthy();
});
});
});
Loading

0 comments on commit 4c6226b

Please sign in to comment.