storebound/apps/desktop/tests/notifications.test.ts

153 lines
9.6 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import type { PortfolioResult } from '../src/api';
import { DEFAULT_SETTINGS } from '../src/api';
import { inQuietHours, isMuted, kindEnabled, mayNotify, nextReadDelay, releaseChanges } from '../src/lib/notify';
import { normalizeSettings } from '../src/lib/normalize';
import { parseRouteJson } from '../src/nav';
import { traySummary, trayPhases } from '../src/lib/tray';
type Rel = { store: 'ios' | 'android'; version: string; state: string; phase: string; reason?: string };
const snap = (...rels: Rel[]): PortfolioResult => ({
apps: [{ project: '/a', ok: true, app: { id: '/a', name: 'A', platforms: { ios: null, android: null } }, summary: null, check: null, catalog: null, storeErrors: {}, errors: [],
release: { source: 'live' as const, current: null, next: null, all: rels.map(r => ({ build: '1', fraction: null, track: null, ...r })) } }],
at: 'x',
} as unknown as PortfolioResult);
const ios = (state: string, phase: string, extra: Partial<Rel> = {}): Rel => ({ store: 'ios', version: '2.0', state, phase, ...extra });
const play = (state: string, phase: string): Rel => ({ store: 'android', version: '2.0', state, phase });
describe('releaseChanges: transitions the owner waits for', () => {
test('App Store in review -> rejected', () => {
const c = releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('REJECTED', 'rejected')));
expect(c).toHaveLength(1);
expect(c[0]).toMatchObject({ store: 'ios', version: '2.0', from: 'review', to: 'rejected', kind: 'rejected' });
});
test('metadata rejected counts, and the reason text comes along', () => {
const c = releaseChanges(snap(ios('WAITING_FOR_REVIEW', 'review')), snap(ios('METADATA_REJECTED', 'rejected', { reason: ' Guideline 2.1 - missing demo account ' })));
expect(c[0]).toMatchObject({ kind: 'rejected', reason: 'Guideline 2.1 - missing demo account' });
});
test('Play in review -> rejected', () => {
const c = releaseChanges(snap(play('inProgress', 'review')), snap(play('rejected', 'rejected')));
expect(c[0]).toMatchObject({ store: 'android', kind: 'rejected' });
});
test('approved (pending developer release)', () => {
const c = releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('PENDING_DEVELOPER_RELEASE', 'approved')));
expect(c[0]).toMatchObject({ to: 'approved', kind: 'approved' });
});
test('live, from review and from approved; a rollout counts as live', () => {
expect(releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('READY_FOR_SALE', 'live')))[0]!.kind).toBe('live');
expect(releaseChanges(snap(ios('PENDING_DEVELOPER_RELEASE', 'approved')), snap(ios('READY_FOR_SALE', 'live')))[0]!.kind).toBe('live');
expect(releaseChanges(snap(play('inProgress', 'review')), snap(play('inProgress', 'rollout')))[0]!.kind).toBe('live');
});
test('halted rollout', () => {
expect(releaseChanges(snap(play('inProgress', 'rollout')), snap(play('halted', 'halted')))[0]!.kind).toBe('halted');
});
test('submitted -> in review is the generic kind; nothing changed or a first read is silent', () => {
expect(releaseChanges(snap(ios('PREPARE_FOR_SUBMISSION', 'draft')), snap(ios('WAITING_FOR_REVIEW', 'review')))[0]!.kind).toBe('state');
expect(releaseChanges(snap(ios('REJECTED', 'rejected')), snap(ios('REJECTED', 'rejected')))).toEqual([]);
expect(releaseChanges(null, snap(ios('REJECTED', 'rejected')))).toEqual([]);
});
test('a version the developer withdrew is not a store decision', () => {
expect(releaseChanges(snap(ios('IN_REVIEW', 'review')), snap(ios('DEVELOPER_REJECTED', 'rejected')))).toEqual([]);
});
test('one store rejected while the other is live: only the rejected one', () => {
const c = releaseChanges(snap(ios('IN_REVIEW', 'review'), play('completed', 'live')), snap(ios('REJECTED', 'rejected'), play('completed', 'live')));
expect(c.map(x => x.store)).toEqual(['ios']);
});
});
describe('who may notify: toggles, quiet hours, mute', () => {
const n = DEFAULT_SETTINGS.notify;
const at = (h: number, m = 0) => new Date(2026, 9, 22, h, m);
test('each kind has its own toggle (halted shares the crash toggle)', () => {
expect(kindEnabled({ ...n, rejected: false }, 'rejected')).toBe(false);
expect(kindEnabled({ ...n, rejected: false }, 'approved')).toBe(true);
expect(kindEnabled({ ...n, approved: false }, 'approved')).toBe(false);
expect(kindEnabled({ ...n, live: false }, 'live')).toBe(false);
expect(kindEnabled({ ...n, crash: false }, 'halted')).toBe(false);
expect(kindEnabled({ ...n, crash: false }, 'crash')).toBe(false);
expect(kindEnabled({ ...n, lowReviews: false }, 'lowReviews')).toBe(false);
expect(kindEnabled({ ...n, reviewState: false }, 'state')).toBe(false);
});
test('quiet hours are off by default and cross midnight', () => {
expect(inQuietHours(n.quietHours, at(23, 30))).toBe(false);
const q = { enabled: true, from: '23:00', to: '08:00' };
expect(inQuietHours(q, at(22, 59))).toBe(false);
expect(inQuietHours(q, at(23, 0))).toBe(true);
expect(inQuietHours(q, at(3))).toBe(true);
expect(inQuietHours(q, at(7, 59))).toBe(true);
expect(inQuietHours(q, at(8, 0))).toBe(false);
expect(inQuietHours(q, at(12))).toBe(false);
});
test('quiet hours inside one day, equal ends and bad times', () => {
const q = { enabled: true, from: '13:00', to: '14:30' };
expect(inQuietHours(q, at(13, 0))).toBe(true);
expect(inQuietHours(q, at(14, 30))).toBe(false);
expect(inQuietHours({ enabled: true, from: '09:00', to: '09:00' }, at(9))).toBe(false);
expect(inQuietHours({ enabled: true, from: '9am', to: '10:00' }, at(9, 30))).toBe(false);
});
test('mute for an hour', () => {
const now = at(12).getTime();
expect(isMuted(0, now)).toBe(false);
expect(isMuted(now + 3_600_000, now)).toBe(true);
expect(isMuted(now - 1, now)).toBe(false);
});
test('mayNotify combines them; a test notification always shows', () => {
const q = { ...n, quietHours: { enabled: true, from: '23:00', to: '08:00' } };
expect(mayNotify(n, 'rejected', { now: at(12) })).toBe(true);
expect(mayNotify(q, 'rejected', { now: at(2) })).toBe(false);
expect(mayNotify(n, 'rejected', { now: at(12), muteUntil: at(12).getTime() + 1000 })).toBe(false);
expect(mayNotify({ ...n, rejected: false }, 'rejected', { now: at(12) })).toBe(false);
expect(mayNotify(q, 'test', { now: at(2), muteUntil: at(2).getTime() + 1000 })).toBe(true);
});
test('settings from an older file get the new notify fields', () => {
const s = normalizeSettings({ notify: { reviewState: false, quietHours: { enabled: true, from: 'x', to: '07:30' } } });
expect(s.notify).toMatchObject({ reviewState: false, rejected: true, approved: true, live: true, quietHours: { enabled: true, from: '23:00', to: '07:30' } });
expect(normalizeSettings({ refresh: { everyMinutes: 30 } }).refresh.everyMinutes).toBe(30);
expect(DEFAULT_SETTINGS.refresh.everyMinutes).toBe(15);
});
});
describe('background reading pace', () => {
test('interval with jitter, doubled per failure, capped', () => {
expect(nextReadDelay(15, 0, 0.5)).toBe(15 * 60_000);
expect(nextReadDelay(15, 0, 0)).toBe(Math.round(15 * 60_000 * 0.9));
expect(nextReadDelay(15, 0, 0.999)).toBeLessThanOrEqual(Math.round(15 * 60_000 * 1.1));
expect(nextReadDelay(15, 1, 0.5)).toBe(30 * 60_000);
expect(nextReadDelay(15, 2, 0.5)).toBe(60 * 60_000);
expect(nextReadDelay(15, 9, 0.5)).toBe(2 * 3_600_000);
expect(nextReadDelay(5, -3, 0.5)).toBe(5 * 60_000);
});
});
describe('tray summary and routes', () => {
const app = (key: string, name: string, ...rels: Rel[]) => ({ key, name, status: snap(...rels).apps[0] });
const tr = { phaseText: (p: string) => p, store: (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Play'), none: '-', muted: 'muted',
tipReview: (n: number) => `${n} in review`, tipAttention: (n: number) => `${n} needs you`,
labels: { open: 'o', readNow: 'r', apps: 'a', noApps: 'n', mute: 'm', unmute: 'u', update: 'U', settings: 's', quit: 'q' } };
test('counts, attention and one-word states', () => {
const s = traySummary([
app('1', 'One', ios('IN_REVIEW', 'review'), play('completed', 'live')),
app('2', 'Two', ios('REJECTED', 'rejected')),
app('3', 'Three', ios('READY_FOR_SALE', 'live'), ios('PREPARE_FOR_SUBMISSION', 'draft')),
{ key: '4', name: 'Four', status: undefined },
], false, tr);
expect(s.tooltip).toBe('storebound · 1 in review · 1 needs you');
expect(s.attention).toBe(1);
expect(s.apps.map(a => a.state)).toEqual(['App Store: review · Play: live', 'App Store: rejected', 'App Store: live', '-']);
});
test('muted shows in the tooltip; nothing going on is just the name', () => {
expect(traySummary([app('1', 'One', ios('READY_FOR_SALE', 'live'))], true, tr).tooltip).toBe('storebound · muted');
expect(traySummary([], false, tr).tooltip).toBe('storebound');
});
test('the most pressing release of a store wins', () => {
expect(trayPhases(snap(ios('READY_FOR_SALE', 'live'), { ...ios('IN_REVIEW', 'review'), version: '3.0' }).apps[0]).ios).toBe('review');
});
test('routes from the host are checked', () => {
expect(parseRouteJson('{"screen":"app","appId":"k","tab":"reviews"}')).toEqual({ screen: 'app', appId: 'k', tab: 'reviews' });
expect(parseRouteJson('{"screen":"app","appId":"k","tab":"nope"}')).toEqual({ screen: 'app', appId: 'k', tab: 'releases' });
expect(parseRouteJson('{"screen":"settings","section":"updates"}')).toEqual({ screen: 'settings', section: 'updates' });
expect(parseRouteJson('{"screen":"app"}')).toBeNull();
expect(parseRouteJson('{"screen":"x"}')).toBeNull();
expect(parseRouteJson('junk')).toBeNull();
});
});