121 lines
8.5 KiB
TypeScript
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: [] });
|
|
});
|
|
});
|