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, } 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('' + 'x'.repeat(350) + '', '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); }); });