play data-safety: PSL_SUPPORT_DATA_DELETION_BY_USER as Play's single choice (partialDeletion true | false | 'auto90'), drop the removed PSL_DATA_COLLECTION_USER_REQUEST_DELETE (deletionRequest deprecated, check notes it); ship Google's empty form (cli/data/play-data-safety-form.csv, exported 2026-10-12), take every Answer requirement from it and report pairs not in it (formPairIssues) in check, declarations and play data-safety. Pitfall 21
This commit is contained in:
parent
dfc28c15f8
commit
b5b8f2da9d
35 changed files with 1824 additions and 859 deletions
|
|
@ -23,7 +23,7 @@ const base: ShipConfig = {
|
|||
apple: { bundleId: 'com.example.game' },
|
||||
play: { packageName: 'com.example.game' },
|
||||
dataSafety: {
|
||||
collectsData: true, encryptedInTransit: true, deletionRequest: false,
|
||||
collectsData: true, encryptedInTransit: true,
|
||||
data: {
|
||||
PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] },
|
||||
PSL_APPROX_LOCATION: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] },
|
||||
|
|
@ -144,16 +144,19 @@ describe('check: appPrivacy.types against the iOS project', () => {
|
|||
try {
|
||||
writeFileSync(join(dir, '.gitignore'), '.storebound/\n');
|
||||
const c = { app: base.app, languages: base.languages, accounts: { enabled: true, signUp: ['username'] },
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false, // deletionRequest: deprecated, a note
|
||||
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } } } as ShipConfig;
|
||||
const out: string[] = [];
|
||||
const orig = console.log;
|
||||
console.log = (s: string) => { out.push(s); };
|
||||
try { await check({ loaded: { config: c, root: dir, path: join(dir, 'storebound.config.ts'), resolve: p => join(dir, p) }, args: parseArgs(['--json']) }); }
|
||||
finally { console.log = orig; process.exitCode = 0; }
|
||||
const problems = (JSON.parse(out.at(-1)!).problems as string[]).join('\n');
|
||||
const res = JSON.parse(out.at(-1)!) as { problems: string[]; warnings: string[] };
|
||||
const problems = res.problems.join('\n');
|
||||
expect(problems).toContain('data safety: missing: PSL_ACCOUNT_DELETION_URL');
|
||||
expect(problems).toContain('set accounts.deletionUrl');
|
||||
expect(problems).not.toContain('PSL_SUPPORT_DATA_DELETION_BY_USER');
|
||||
expect(res.warnings.join('\n')).toContain('dataSafety.deletionRequest is no longer used: Play removed that question');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -141,15 +141,15 @@ describe('review access and demo account', () => {
|
|||
|
||||
describe('profile checks (offline)', () => {
|
||||
test('accounts need a deletion URL and consistent Data safety; sign-in needs a demo account; subscriptions need terms', () => {
|
||||
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: false, data: {} };
|
||||
const ds = { collectsData: true, encryptedInTransit: true, data: {} };
|
||||
const msgs = profileIssues(base({ accounts: { enabled: true }, reviewAccess: { signInRequired: true }, dataSafety: ds,
|
||||
subscriptions: [group([sub()])] }), { demoAccount: false });
|
||||
const text = msgs.map(m => `${m.level} ${m.msg}`).join('\n');
|
||||
for (const m of ['error accounts.enabled: set accounts.deletionUrl', 'warn accounts.enabled: set accounts.inApp', 'PSL_ACM_NONE', 'deletionRequest is false',
|
||||
for (const m of ['error accounts.enabled: set accounts.deletionUrl', 'warn accounts.enabled: set accounts.inApp', 'PSL_ACM_NONE',
|
||||
'error App Review needs a demo account', 'error subscriptions: set app.termsUrl', 'without reviewScreenshot']) expect(text).toContain(m);
|
||||
expect(profileIssues(base({ app: { ...APP, termsUrl: 'https://example.com/t' }, accounts: { enabled: true, deletionUrl: 'https://example.com/d', inApp: 'Settings' },
|
||||
reviewAccess: { signInRequired: true, instructions: 'x' }, subscriptions: [group([sub({ reviewScreenshot: 'p.png' })])],
|
||||
dataSafety: { ...ds, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'] } }), { demoAccount: true })).toEqual([]);
|
||||
dataSafety: { ...ds, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'] } }), { demoAccount: true })).toEqual([]);
|
||||
});
|
||||
test('profileRules (validate): Play needs a reachable delete-account page, Apple needs the demo account on App Review, terms linked', () => {
|
||||
const ok = { url: 'https://example.com/d', status: 200 };
|
||||
|
|
@ -388,10 +388,10 @@ describe('app screenshot style, data safety extras, tools, skills', () => {
|
|||
expect(readFileSync(join(ROOT, 'cli', 'shots', 'template-app.html'), 'utf8')).not.toContain('class="spark"');
|
||||
});
|
||||
test('Data safety extra rows are appended as given', () => {
|
||||
const rows = dataSafetyRows({ collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], data: {},
|
||||
const rows = dataSafetyRows({ collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], data: {},
|
||||
extra: [{ question: 'PSL_EXAMPLE_URL', value: 'https://example.com/delete-account' }] });
|
||||
expect(rows.at(-1)).toEqual(['PSL_EXAMPLE_URL', '', 'https://example.com/delete-account', 'MAYBE_REQUIRED', 'PSL_EXAMPLE_URL']);
|
||||
expect(validateConfig(base({ dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, data: {}, extra: [{ question: 'bad id', value: 'x' }] } })).join()).toContain('dataSafety.extra[0]');
|
||||
expect(validateConfig(base({ dataSafety: { collectsData: true, encryptedInTransit: true, data: {}, extra: [{ question: 'bad id', value: 'x' }] } })).join()).toContain('dataSafety.extra[0]');
|
||||
});
|
||||
test('MCP: subscriptions are dry-run write tools, catalog diff is read-only', () => {
|
||||
const t = (n: string) => TOOLS.find(x => x.name === n)!;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const config: ShipConfig = {
|
|||
products: [{ id: 'com.example.game.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No ads', description: 'Removes ads' } } }],
|
||||
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
|
||||
dataSafety: {
|
||||
collectsData: true, encryptedInTransit: true, deletionRequest: false,
|
||||
collectsData: true, encryptedInTransit: true,
|
||||
data: { PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] }, PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] } },
|
||||
},
|
||||
console: { contentRating: { answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'] } },
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const base0: ShipConfig = {
|
|||
apple: { bundleId: 'com.example.game', primaryLocale: 'en-US' },
|
||||
play: { packageName: 'com.example.game' },
|
||||
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: false, data: { PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] } } },
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] } } },
|
||||
console: { contentRating: { answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'] } },
|
||||
} as ShipConfig;
|
||||
/** Every App Privacy answer certain: Device ID and Crash Data collected, nothing else. */
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ describe('declarations', () => {
|
|||
});
|
||||
test('Data safety answers become App Privacy suggestions; linked / tracking stay questions', () => {
|
||||
const cfg = base({
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, data: {
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, data: {
|
||||
PSL_DEVICE_ID: { shared: true, collect: ['analytics'], share: ['advertising'] }, PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] } } },
|
||||
apple: { bundleId: 'com.example.demo', usesNonExemptEncryption: false, info: { categories: { primary: 'UTILITIES' }, ageRating: {}, contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT' } },
|
||||
reviewAccess: { signInRequired: false },
|
||||
|
|
@ -39,12 +39,12 @@ describe('declarations', () => {
|
|||
expect(applePurpose('communications')).toBe('App Functionality');
|
||||
});
|
||||
test('changes since the last upload compare answers, not labels', () => {
|
||||
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: false, data: { PSL_CRASH_LOGS: { shared: false, collect: ['analytics' as const] } } };
|
||||
const ds = { collectsData: true, encryptedInTransit: false, data: { PSL_CRASH_LOGS: { shared: false, collect: ['analytics' as const] } } };
|
||||
const before = dataSafetyCsv(ds);
|
||||
expect(dataSafetyChanges(before, before)).toEqual([]);
|
||||
const after = dataSafetyCsv({ ...ds, deletionRequest: true });
|
||||
expect(dataSafetyChanges(before, after)).toEqual([{ question: 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE', response: '', from: 'FALSE', to: 'TRUE' }]);
|
||||
const d = declarations(base({ dataSafety: { ...ds, deletionRequest: true } }), { ...none, uploaded: { csv: before, at: '2026-10-01T00:00:00Z' } });
|
||||
const after = dataSafetyCsv({ ...ds, encryptedInTransit: true });
|
||||
expect(dataSafetyChanges(before, after)).toEqual([{ question: 'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', response: '', from: 'FALSE', to: 'TRUE' }]);
|
||||
const d = declarations(base({ dataSafety: { ...ds, encryptedInTransit: true } }), { ...none, uploaded: { csv: before, at: '2026-10-01T00:00:00Z' } });
|
||||
expect(d.dataSafety!.uploaded!.changes.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
|
|||
});
|
||||
|
||||
test('play data-safety: the same CSV is not sent again (hash in .storebound/); a changed one or --force is', async () => {
|
||||
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: false, data: { PSL_DEVICE_ID: { shared: false, collect: ['analytics'] } } };
|
||||
const ds = { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: false, collect: ['analytics'] } } };
|
||||
const p = project({ ...CONFIG, dataSafety: ds as never });
|
||||
const r = await twice(() => playDataSafety(p.ctx([])));
|
||||
expect(r.first).toEqual(['POST /androidpublisher/v3/applications/com.example.notes/dataSafety']);
|
||||
|
|
@ -349,7 +349,7 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
|
|||
|
||||
test('play data-safety: a missing conditional row stops --dry and the real run before any POST', async () => {
|
||||
// STAR DASH: accounts with a username, no deletion URL in the CSV -> Play 400 "Response missing for PSL_ACCOUNT_DELETION_URL"
|
||||
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
|
||||
const ds = { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
|
||||
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } };
|
||||
const p = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'] }, dataSafety: ds as never });
|
||||
const before = s.calls.length;
|
||||
|
|
@ -359,7 +359,10 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
|
|||
const q = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'], deletionUrl: 'https://example.com/delete-account' }, dataSafety: ds as never });
|
||||
await capture(() => playDataSafety(q.ctx([])));
|
||||
expect(s.play.dataSafety.at(-1)).toContain('PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,');
|
||||
expect(s.play.dataSafety.at(-1)).toContain('PSL_SUPPORT_DATA_DELETION_BY_USER,,FALSE,');
|
||||
// a single choice: the picked option carries TRUE (a bare FALSE was Play's 400 "Line 7: Response ID missing")
|
||||
expect(s.play.dataSafety.at(-1)).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,');
|
||||
expect(s.play.dataSafety.at(-1)).not.toContain('PSL_SUPPORT_DATA_DELETION_BY_USER,,');
|
||||
expect(s.play.dataSafety.at(-1)).not.toContain('PSL_DATA_COLLECTION_USER_REQUEST_DELETE');
|
||||
});
|
||||
|
||||
test('apple promoted: created and ordered once', async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { join } from 'node:path';
|
|||
import { parseArgs } from '../cli/lib/args';
|
||||
import { loadConfig, validateConfig } from '../cli/lib/config';
|
||||
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
|
||||
import { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from '../cli/lib/data-safety';
|
||||
import { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows, formPairIssues, formRow, playForm, PLAY_FORM_EXPORTED } from '../cli/lib/data-safety';
|
||||
import type { DataSafety } from '../cli/lib/config';
|
||||
import { parseCsv } from '../cli/lib/csv';
|
||||
import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money';
|
||||
import { maskExpr } from '../cli/icons';
|
||||
|
|
@ -27,11 +28,11 @@ describe('config', () => {
|
|||
languages: [{ id: 'en' }, { id: 'en' }],
|
||||
play: { packageName: 'notapackage' },
|
||||
products: [{ id: 'bad id!', type: 'gift', usd: 0, listings: { de: { title: '', description: 'x' } }, overrides: [{ region: 'TUR', currency: 'tl', price: 1 }] }],
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, data: { DEVICE: { shared: true, collect: [] } } },
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, partialDeletion: 'later' as never, data: { DEVICE: { shared: true, collect: [] } } },
|
||||
});
|
||||
const text = e.join('\n');
|
||||
for (const s of ['privacyPolicyUrl', 'contactEmail', 'duplicate "en"', 'packageName', 'products[0].id', 'products[0].type', 'products[0].usd',
|
||||
'listings.de: not in languages', 'region "TUR"', 'currency "tl"', 'dataSafety.data.DEVICE', 'sharing purposes']) expect(text).toContain(s);
|
||||
'listings.de: not in languages', 'region "TUR"', 'currency "tl"', 'dataSafety.data.DEVICE', 'sharing purposes', "dataSafety.partialDeletion: true | false | 'auto90'"]) expect(text).toContain(s);
|
||||
});
|
||||
test('empty config', () => { expect(validateConfig(null)).toEqual(['config must be an object']); });
|
||||
});
|
||||
|
|
@ -69,14 +70,16 @@ describe('copy parsing', () => {
|
|||
});
|
||||
|
||||
describe('data safety csv', () => {
|
||||
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: false,
|
||||
const ds = { collectsData: true, encryptedInTransit: true,
|
||||
data: { PSL_DEVICE_ID: { shared: true, collect: ['analytics' as const, 'advertising' as const], share: ['advertising' as const] },
|
||||
PSL_OTHER_PERSONAL: { shared: false, optional: true, collect: ['functionality' as const] } } };
|
||||
const rows = dataSafetyRows(ds);
|
||||
const find = (q: string, r: string) => rows.find(x => x[0] === q && x[1] === r);
|
||||
test('header + overview + account creation', () => {
|
||||
expect(rows[0]![0]).toBe('Question ID (machine readable)');
|
||||
expect(find('PSL_DATA_COLLECTION_USER_REQUEST_DELETE', '')![2]).toBe('FALSE');
|
||||
// Play removed this question from the form; writing it is a 400
|
||||
expect(rows.some(r => r[0] === 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBe(false);
|
||||
expect(dataSafetyRows({ ...ds, deletionRequest: true }).some(r => r[0] === 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBe(false);
|
||||
expect(find('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', 'PSL_ACM_NONE')![2]).toBe('TRUE');
|
||||
});
|
||||
test('types and purposes', () => {
|
||||
|
|
@ -101,18 +104,31 @@ describe('data safety csv', () => {
|
|||
});
|
||||
|
||||
describe('data safety: conditional required rows', () => {
|
||||
const acc = { ...{ collectsData: true, encryptedInTransit: true, deletionRequest: true }, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'],
|
||||
const acc = { ...{ collectsData: true, encryptedInTransit: true }, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'],
|
||||
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account' as const] } } };
|
||||
const url = 'https://example.com/delete-account';
|
||||
const get = (rows: string[][], q: string) => rows.find(r => r[0] === q);
|
||||
test('accounts + username: the delete-account URL row carries accounts.deletionUrl', () => {
|
||||
const rows = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url });
|
||||
expect(get(rows, 'PSL_ACCOUNT_DELETION_URL')![2]).toBe(url);
|
||||
expect(get(rows, 'PSL_SUPPORT_DATA_DELETION_BY_USER')![2]).toBe('FALSE');
|
||||
// a SINGLE_CHOICE: one row per option, TRUE on the picked one (a value with no Response ID was Play's
|
||||
// "Line 7: Response ID missing")
|
||||
expect(rows.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER').map(r => r.slice(0, 4).join(','))).toEqual([
|
||||
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,,SINGLE_CHOICE',
|
||||
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,SINGLE_CHOICE',
|
||||
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO_AUTO_DELETED,,SINGLE_CHOICE',
|
||||
]);
|
||||
expect(dataSafetyCsv({ ...acc, partialDeletion: false }, { deletionUrl: url })).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,');
|
||||
expect(get(rows, 'PSL_DATA_DELETION_URL')).toBeUndefined();
|
||||
expect(dataSafetyIssues(rows)).toEqual([]);
|
||||
const yes = dataSafetyRows({ ...acc, partialDeletion: true }, { deletionUrl: url });
|
||||
expect(get(yes, 'PSL_DATA_DELETION_URL')![2]).toBe(url);
|
||||
expect(yes.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1])).toEqual(['DATA_DELETION_YES']);
|
||||
expect(dataSafetyIssues(yes)).toEqual([]);
|
||||
const auto = dataSafetyRows({ ...acc, partialDeletion: 'auto90' }, { deletionUrl: url });
|
||||
expect(auto.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1])).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
|
||||
expect(get(auto, 'PSL_DATA_DELETION_URL')).toBeUndefined();
|
||||
expect(dataSafetyIssues(auto)).toEqual([]);
|
||||
expect(get(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: 'https://example.com/data' }, { deletionUrl: url }), 'PSL_DATA_DELETION_URL')![2]).toBe('https://example.com/data');
|
||||
});
|
||||
test('no accounts: no deletion rows, no issues', () => {
|
||||
|
|
@ -142,11 +158,57 @@ describe('data safety: conditional required rows', () => {
|
|||
test('the template passes the rules and only uses known question ids', () => {
|
||||
const rows = parseCsv(readFileSync(join(import.meta.dir, '..', 'templates', 'data-safety.csv'), 'utf8'));
|
||||
expect(dataSafetyIssues(rows)).toEqual([]);
|
||||
// an example with answers, not the empty form, and every pair is in Play's current form
|
||||
expect(rows.some(r => r[2] === 'TRUE')).toBe(true);
|
||||
expect(formPairIssues(rows)).toEqual([]);
|
||||
for (const r of rows.slice(1)) expect(r[3]).toBe(formRow(r[0]!, r[1]!)!.requirement);
|
||||
const known = new Set(DATA_SAFETY_RULES.map(r => r.question));
|
||||
for (const r of rows.slice(1)) expect(known.has(r[0]!) || /^PSL_DATA_TYPES_|^PSL_DATA_USAGE_RESPONSES:PSL_[A-Z_]+:/.test(r[0]!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("data safety: pairs from Play's exported form", () => {
|
||||
const url = 'https://example.com/delete-account';
|
||||
const all = (u: Partial<DataSafety['data'][string]> = {}) => Object.fromEntries(['PSL_NAME', 'PSL_EMAIL', 'PSL_PURCHASE_HISTORY', 'PSL_APPROX_LOCATION', 'PSL_PRECISE_LOCATION',
|
||||
'PSL_CRASH_LOGS', 'PSL_USER_INTERACTION', 'PSL_DEVICE_ID', 'PSL_PHOTOS', 'PSL_HEALTH'].map(t => [t, { shared: true, collect: ['analytics', 'functionality'], share: ['advertising'], ...u }])) as DataSafety['data'];
|
||||
const base: DataSafety = { collectsData: true, encryptedInTransit: true, data: {} };
|
||||
const accounts = { ...base, accountCreation: ['PSL_ACM_USER_ID_PASSWORD', 'PSL_ACM_OAUTH'], data: { PSL_EMAIL: { shared: false, collect: ['account' as const] } } };
|
||||
const cases: [string, DataSafety][] = [
|
||||
['no data', { collectsData: false, encryptedInTransit: false, data: {} }],
|
||||
['accounts, partialDeletion true', { ...accounts, partialDeletion: true }],
|
||||
['accounts, partialDeletion false', { ...accounts, partialDeletion: false }],
|
||||
["accounts, partialDeletion 'auto90'", { ...accounts, partialDeletion: 'auto90' }],
|
||||
['many types shared and collected', { ...base, data: all() }],
|
||||
['many types, optional and ephemeral', { ...base, data: all({ shared: false, optional: true, ephemeral: true }) }],
|
||||
['extra rows', { ...base, data: all(), extra: [{ question: 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY', value: 'TRUE' }, { question: 'PSL_INDEPENDENTLY_VALIDATED', value: 'FALSE' },
|
||||
{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'TRUE' }, { question: 'PSL_OUTSIDE_APP_ACCOUNT_TYPES', response: 'PSL_LOGIN_THROUGH_EMPLOYMENT_OR_ENTERPRISE_ACCOUNT', value: 'TRUE' }] }],
|
||||
];
|
||||
test(`the form (exported ${PLAY_FORM_EXPORTED}) is Google's empty export`, () => {
|
||||
const f = [...playForm().values()];
|
||||
expect(f.length).toBe(782);
|
||||
expect(formRow('PSL_DATA_COLLECTION_USER_REQUEST_DELETE')).toBeUndefined();
|
||||
expect(formRow('PSL_SUPPORT_DATA_DELETION_BY_USER', 'DATA_DELETION_NO')!.requirement).toBe('SINGLE_CHOICE');
|
||||
expect(formRow('PSL_SUPPORT_DATA_DELETION_BY_USER')).toBeUndefined();
|
||||
});
|
||||
for (const [name, ds] of cases) test(`${name}: every pair is in the form with its Answer requirement`, () => {
|
||||
const rows = dataSafetyRows(ds, { deletionUrl: url });
|
||||
expect(formPairIssues(rows)).toEqual([]);
|
||||
for (const r of rows.slice(1)) expect([r[0], r[1], r[3]]).toEqual([r[0], r[1], formRow(r[0]!, r[1]!)!.requirement]);
|
||||
});
|
||||
test('extra rows take the requirement from the form, not a guess', () => {
|
||||
const rows = dataSafetyRows({ ...base, extra: [{ question: 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY', value: 'TRUE' }, { question: 'PSL_OUTSIDE_APP_ACCOUNT_TYPES', response: 'PSL_OUTSIDE_APP_ACCOUNT_TYPE_OTHER', value: 'TRUE', label: 'mine' }] });
|
||||
expect(rows.find(r => r[0] === 'PSL_DATA_COLLECTION_COMPLIES_FAMILY_POLICY')!.slice(2, 4)).toEqual(['TRUE', 'OPTIONAL']);
|
||||
expect(rows.find(r => r[0] === 'PSL_OUTSIDE_APP_ACCOUNT_TYPES')!.slice(2)).toEqual(['TRUE', 'MULTIPLE_CHOICE', 'mine']);
|
||||
});
|
||||
test('an unknown pair is reported by dataSafetyIssues (check, declarations, play data-safety --dry)', () => {
|
||||
const rows = dataSafetyRows({ ...base, extra: [{ question: 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE', value: 'TRUE' }, { question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', value: 'FALSE' }] });
|
||||
const msgs = dataSafetyIssues(rows).map(i => i.msg);
|
||||
expect(msgs).toContain(`PSL_DATA_COLLECTION_USER_REQUEST_DELETE, is not in Play's current Data safety form (cli/data/play-data-safety-form.csv, exported ${PLAY_FORM_EXPORTED}); Play answers 400`);
|
||||
expect(msgs.some(m => m.startsWith("PSL_SUPPORT_DATA_DELETION_BY_USER, is not in Play's current"))).toBe(true);
|
||||
expect(formPairIssues([['PSL_DATA_TYPES_PERSONAL', 'PSL_NOPE', 'TRUE', 'MULTIPLE_CHOICE', '']]).map(i => i.question)).toEqual(['PSL_DATA_TYPES_PERSONAL']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prices', () => {
|
||||
test('toMoney without float noise', () => {
|
||||
expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue