storebound/tests/reviews.test.ts

121 lines
8.5 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { parseFrontMatter, stringifyFrontMatter } from '../cli/lib/frontmatter';
import {
checkReply, draftMarkdown, draftName, filterReviews, markSent, parseAppleReviews, parseDraft, parsePlayReviews, REPLY_LIMITS, REPLY_PLACEHOLDER,
replyRequest, sendPlan, settleStoreReads,
} from '../cli/lib/reviews';
import { FORBIDDEN_ARGS, TOOLS, toArgv } from '../cli/mcp-tools';
const appleResp = {
data: [
{ id: 'r1', attributes: { rating: 2, title: 'Too many ads', body: 'Fun but ads after every level.', reviewerNickname: 'nick1', createdDate: '2026-10-08T09:00:00-07:00', territory: 'USA' }, relationships: { response: { data: { id: 'resp1' } } } },
{ id: 'r2', attributes: { rating: 5, title: '', body: 'Love it', reviewerNickname: 'nick2', createdDate: '2026-10-09T09:00:00-07:00', territory: 'TUR' }, relationships: { response: { data: null } } },
],
included: [{ type: 'customerReviewResponses', id: 'resp1', attributes: { responseBody: 'Thanks!', lastModifiedDate: '2026-10-08T12:00:00-07:00', state: 'PUBLISHED' } }],
};
const playResp = {
reviews: [{ reviewId: 'gp:AOqp-1', authorName: 'A Player', comments: [
{ userComment: { text: 'Crashes\tIt crashes on level 3.', lastModified: { seconds: '1791300000' }, starRating: 1, reviewerLanguage: 'en', appVersionName: '1.2.0' } },
{ developerComment: { text: 'Sorry, fixed in 1.2.1.', lastModified: { seconds: '1791400000' } } }] },
{ reviewId: 'gp:AOqp-2', comments: [{ userComment: { text: 'Nice game', lastModified: { seconds: '1791200000' }, starRating: 4, reviewerLanguage: 'tr' } }] }],
};
describe('front matter', () => {
test('round trip with quoting', () => {
const t = stringifyFrontMatter({ store: 'play', review_id: 'gp:AOqp-1', rating: 1, approved: false, sent: null, note: 'a: b, "c"' }, 'body\n');
expect(t.startsWith('---\nstore: play\nreview_id: gp:AOqp-1\nrating: 1\napproved: false\nsent: null\nnote: "a: b, \\"c\\""\n---\n')).toBe(true);
expect(parseFrontMatter(t).data).toEqual({ store: 'play', review_id: 'gp:AOqp-1', rating: 1, approved: false, sent: null, note: 'a: b, "c"' });
expect(parseFrontMatter('no front matter').body).toBe('no front matter');
expect(parseFrontMatter('---\nx: \'it\'\'s\'\ny: true\n---\n').data).toEqual({ x: "it's", y: true });
});
});
describe('reviews', () => {
test('apple and play responses normalise to one shape', () => {
const a = parseAppleReviews(appleResp);
expect(a[0]).toMatchObject({ store: 'apple', id: 'r1', rating: 2, title: 'Too many ads', where: 'USA', reply: { text: 'Thanks!', state: 'PUBLISHED' } });
expect(a[1]!.title).toBeUndefined();
expect(a[1]!.reply).toBeUndefined();
const p = parsePlayReviews(playResp);
expect(p[0]).toMatchObject({ store: 'play', id: 'gp:AOqp-1', rating: 1, title: 'Crashes', body: 'It crashes on level 3.', where: 'en', appVersion: '1.2.0', reply: { text: 'Sorry, fixed in 1.2.1.' } });
expect(p[0]!.date).toBe(new Date(1791300000 * 1000).toISOString());
expect(p[1]).toMatchObject({ body: 'Nice game', rating: 4 });
const all = [...a, ...p];
expect(filterReviews(all, { unanswered: true }).map(r => r.id)).toEqual(['r2', 'gp:AOqp-2']);
expect(filterReviews(all, { ratings: [1, 2] }).map(r => r.id).sort()).toEqual(['gp:AOqp-1', 'r1']);
expect(filterReviews(all, { since: '2026-10-09' }).map(r => r.id)).toEqual(['r2']);
});
test('draft file: name, front matter, quote, empty reply; parse back', () => {
const r = parsePlayReviews(playResp)[0]!;
expect(draftName(r)).toBe('play-gp_AOqp-1.md');
const md = draftMarkdown(r);
expect(md).toContain('approved: false');
expect(md).toContain('limit: 350');
expect(md).toContain('> It crashes on level 3.');
expect(md).toContain('## Current reply');
expect(md).toContain(REPLY_PLACEHOLDER);
const d = parseDraft('x.md', md);
expect(d).toMatchObject({ store: 'play', reviewId: 'gp:AOqp-1', approved: false, sent: null, reply: '' });
const filled = md.replace(REPLY_PLACEHOLDER, 'Thanks for telling us. Version 1.2.1 fixes the crash on level 3.').replace('approved: false', 'approved: true');
const d2 = parseDraft('x.md', filled);
expect(d2.reply).toBe('Thanks for telling us. Version 1.2.1 fixes the crash on level 3.');
expect(d2.approved).toBe(true);
const sent = markSent(filled, '2026-10-10T10:00:00.000Z');
expect(parseDraft('x.md', sent).sent).toBe('2026-10-10T10:00:00.000Z');
expect(() => parseDraft('bad.md', '---\nstore: web\n---\n')).toThrow('apple or play');
});
test('reply checks: limits, incentives, personal data, tone', () => {
expect(REPLY_LIMITS).toEqual({ play: 350, apple: 5970 });
expect(checkReply('Thanks! We fixed it in 1.2.1.', 'play')).toEqual([]);
expect(checkReply('', 'apple')[0]).toEqual({ level: 'error', msg: 'empty reply' });
expect(checkReply('x'.repeat(351), 'play')[0]!.level).toBe('error');
expect(checkReply('<b>' + 'x'.repeat(350) + '</b>', 'play')).toEqual([]);
expect(checkReply('x'.repeat(5971), 'apple')[0]!.level).toBe('warn');
for (const bad of ['Update your rating and get 500 free coins!', 'Please give us 5 stars', 'Could you change your review?', 'We will send you free gems', 'Rate us again'])
expect({ bad, err: checkReply(bad, 'apple').some(i => i.level === 'error') }).toEqual({ bad, err: true });
expect(checkReply('Write to support@example.com', 'apple')[0]!.level).toBe('warn');
expect(checkReply('Call +1 555 010 0199', 'apple')[0]!.msg).toContain('phone');
expect(checkReply('See https://example.com/help', 'apple')[0]!.msg).toContain('link');
expect(checkReply('SORRY!!!', 'apple').some(i => i.msg.includes('shouting'))).toBe(true);
});
test('send plan: only approved, unsent, passing drafts; requests per store', () => {
const base = { meta: {}, file: 'f' };
const plan = sendPlan([
{ ...base, file: 'a', store: 'apple', reviewId: 'r1', approved: true, sent: null, reply: 'Thanks for the feedback, we cut the ads in 1.3.' },
{ ...base, file: 'b', store: 'play', reviewId: 'p1', approved: false, sent: null, reply: 'ok' },
{ ...base, file: 'c', store: 'play', reviewId: 'p2', approved: true, sent: '2026-10-01T00:00:00Z', reply: 'ok' },
{ ...base, file: 'd', store: 'play', reviewId: 'p3', approved: true, sent: null, reply: 'Give us 5 stars for free coins' },
]);
expect(plan.send.map(d => d.file)).toEqual(['a']);
expect(plan.skip.map(s => s.file)).toEqual(['b', 'c', 'd']);
expect(plan.skip[0]!.why).toContain('not approved');
expect(plan.skip[2]!.why).toContain('incentives');
expect(replyRequest(plan.send[0]!)).toEqual({ method: 'POST', url: '/v1/customerReviewResponses', body: { data: { type: 'customerReviewResponses', attributes: { responseBody: 'Thanks for the feedback, we cut the ads in 1.3.' }, relationships: { review: { data: { type: 'customerReviews', id: 'r1' } } } } } });
const pr = replyRequest({ ...base, store: 'play', reviewId: 'gp:AOqp-1', approved: true, sent: null, reply: 'Hi' }, 'com.example.game');
expect(pr.url).toBe('https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.game/reviews/gp%3AAOqp-1:reply');
expect(pr.body).toEqual({ replyText: 'Hi' });
});
test('mcp: list + draft only, never send', () => {
const t = (n: string) => TOOLS.find(x => x.name === n)!;
expect(t('reviews_list')).toMatchObject({ write: false });
expect(toArgv(t('reviews_list'), { store: 'play', unanswered: true, rating: '1,2' }).argv).toEqual(['reviews', 'list', '--store', 'play', '--unanswered', '--rating', '1,2']);
expect(toArgv(t('reviews_draft'), { days: 7 }).argv).toEqual(['reviews', 'draft', '--days', '7', '--dry']);
expect(FORBIDDEN_ARGS).toContain('send');
expect(TOOLS.some(x => x.command.includes('send'))).toBe(false);
});
});
describe('settleStoreReads', () => {
const r = { store: 'apple' as const, id: '1', rating: 5, body: 'x', date: '2026-10-01T00:00:00.000Z' };
test('one failed store does not hide the other, and is reported', () => {
const o = settleStoreReads([{ store: 'apple', reviews: [r] }, { store: 'play', error: 'denied' }]);
expect(o.reviews).toEqual([r]);
expect(o.errors).toEqual(['play: denied']);
});
test('every asked store failed: throws instead of an empty "all fine" list', () => {
expect(() => settleStoreReads([{ store: 'apple', error: 'expired key' }])).toThrow('apple: expired key');
});
test('no store asked: empty, no error', () => {
expect(settleStoreReads([])).toEqual({ reviews: [], errors: [] });
});
});