diff --git a/src/utils/billing.test.ts b/src/utils/billing.test.ts index c660119..cb15cec 100644 --- a/src/utils/billing.test.ts +++ b/src/utils/billing.test.ts @@ -3,9 +3,148 @@ import { DEFAULT_MONTHLY_PRICE_FACTOR, getAnnualSavings, getBillingOptions, + parseBillingMonths, resolveBillingPlan, + resolveMonthlyPrice, + resolveMonthlyPriceFactor, } from './billing'; +// ─── resolveMonthlyPriceFactor ────────────────────────────────────── + +describe('resolveMonthlyPriceFactor', () => { + test('returns default factor when no value provided', () => { + expect(resolveMonthlyPriceFactor()).toBe(DEFAULT_MONTHLY_PRICE_FACTOR); + }); + + test('returns default factor for undefined', () => { + expect(resolveMonthlyPriceFactor(undefined)).toBe( + DEFAULT_MONTHLY_PRICE_FACTOR, + ); + }); + + test('returns default factor for 0', () => { + expect(resolveMonthlyPriceFactor(0)).toBe(DEFAULT_MONTHLY_PRICE_FACTOR); + }); + + test('returns default factor for negative number', () => { + expect(resolveMonthlyPriceFactor(-5)).toBe(DEFAULT_MONTHLY_PRICE_FACTOR); + }); + + test('returns default factor for NaN', () => { + expect(resolveMonthlyPriceFactor(NaN)).toBe(DEFAULT_MONTHLY_PRICE_FACTOR); + }); + + test('returns default factor for Infinity', () => { + expect(resolveMonthlyPriceFactor(Infinity)).toBe( + DEFAULT_MONTHLY_PRICE_FACTOR, + ); + }); + + test('returns default factor for string', () => { + expect(resolveMonthlyPriceFactor('abc')).toBe( + DEFAULT_MONTHLY_PRICE_FACTOR, + ); + }); + + test('returns custom factor for valid positive number', () => { + expect(resolveMonthlyPriceFactor(10)).toBe(10); + }); + + test('returns custom factor for decimal', () => { + expect(resolveMonthlyPriceFactor(8.5)).toBe(8.5); + }); +}); + +// ─── resolveMonthlyPrice ──────────────────────────────────────────── + +describe('resolveMonthlyPrice', () => { + test('divides annual price by default factor', () => { + expect(resolveMonthlyPrice(800)).toBe(100); + }); + + test('divides annual price by custom factor', () => { + expect(resolveMonthlyPrice(1000, 10)).toBe(100); + }); + + test('rounds to 2 decimal places', () => { + expect(resolveMonthlyPrice(100, 3)).toBe(33.33); + }); + + test('handles zero annual price', () => { + expect(resolveMonthlyPrice(0)).toBe(0); + }); + + test('handles large values', () => { + expect(resolveMonthlyPrice(120000, 12)).toBe(10000); + }); + + test('rounds correctly at boundary', () => { + expect(resolveMonthlyPrice(100)).toBe(12.5); + }); + + test('handles rounding edge case with EPSILON', () => { + expect(resolveMonthlyPrice(1, 3)).toBe(0.33); + }); +}); + +// ─── parseBillingMonths ───────────────────────────────────────────── + +describe('parseBillingMonths', () => { + test('parses valid number', () => { + expect(parseBillingMonths(6)).toBe(6); + }); + + test('parses numeric string', () => { + expect(parseBillingMonths('3')).toBe(3); + }); + + test('truncates decimal', () => { + expect(parseBillingMonths(3.7)).toBe(3); + }); + + test('truncates decimal string', () => { + expect(parseBillingMonths('5.9')).toBe(5); + }); + + test('returns fallback for 0', () => { + expect(parseBillingMonths(0)).toBe(1); + }); + + test('returns fallback for negative', () => { + expect(parseBillingMonths(-1)).toBe(1); + }); + + test('returns fallback for NaN', () => { + expect(parseBillingMonths(NaN)).toBe(1); + }); + + test('returns fallback for Infinity', () => { + expect(parseBillingMonths(Infinity)).toBe(1); + }); + + test('returns fallback for non-numeric string', () => { + expect(parseBillingMonths('abc')).toBe(1); + }); + + test('returns custom fallback', () => { + expect(parseBillingMonths('abc', 12)).toBe(12); + }); + + test('returns default fallback for null', () => { + expect(parseBillingMonths(null)).toBe(1); + }); + + test('returns default fallback for undefined', () => { + expect(parseBillingMonths(undefined)).toBe(1); + }); + + test('returns fallback for empty string', () => { + expect(parseBillingMonths('')).toBe(1); + }); +}); + +// ─── resolveBillingPlan (expanded) ────────────────────────────────── + describe('resolveBillingPlan', () => { test('uses annual price divided by the default factor for one month', () => { const plan = resolveBillingPlan(800, 1, DEFAULT_MONTHLY_PRICE_FACTOR); @@ -27,8 +166,59 @@ describe('resolveBillingPlan', () => { expect(plan.billingCycle).toBe('year'); expect(plan.switchedToAnnual).toBe(true); }); + + test('defaults to 12 months when no months specified', () => { + const plan = resolveBillingPlan(800); + expect(plan.billingMonths).toBe(12); + expect(plan.billingCycle).toBe('year'); + expect(plan.switchedToAnnual).toBe(true); + }); + + test('throws for months less than 1 after parsing', () => { + expect(() => + resolveBillingPlan(800, 0, DEFAULT_MONTHLY_PRICE_FACTOR), + ).toThrow('Billing months must be a positive integer'); + }); + + test('stores the monthly price factor in the plan', () => { + const plan = resolveBillingPlan(800, 1, 10); + expect(plan.monthlyPriceFactor).toBe(10); + }); + + test('calculates correct amount for 3 months', () => { + const plan = resolveBillingPlan(800, 3, DEFAULT_MONTHLY_PRICE_FACTOR); + expect(plan.amount).toBe(300); + expect(plan.billingCycle).toBe('month'); + expect(plan.requestedMonths).toBe(3); + expect(plan.billingMonths).toBe(3); + }); + + test('handles zero annual price gracefully', () => { + const plan = resolveBillingPlan(0, 1, DEFAULT_MONTHLY_PRICE_FACTOR); + expect(plan.amount).toBe(0); + expect(plan.switchedToAnnual).toBe(false); + expect(plan.billingCycle).toBe('month'); + }); + + test('handles custom monthly price factor', () => { + const plan = resolveBillingPlan(1200, 6, 10); + expect(plan.monthlyPrice).toBe(120); + expect(plan.amount).toBe(720); + expect(plan.billingCycle).toBe('month'); + + const plan2 = resolveBillingPlan(1200, 10, 10); + expect(plan2.switchedToAnnual).toBe(true); + expect(plan2.billingCycle).toBe('year'); + }); + + test('requestedMonths is preserved in output', () => { + const plan = resolveBillingPlan(800, 5, DEFAULT_MONTHLY_PRICE_FACTOR); + expect(plan.requestedMonths).toBe(5); + }); }); +// ─── getAnnualSavings (expanded) ──────────────────────────────────── + describe('getAnnualSavings', () => { test('returns annual savings compared with paying monthly', () => { const plan = resolveBillingPlan(800, 12, DEFAULT_MONTHLY_PRICE_FACTOR); @@ -47,8 +237,35 @@ describe('getAnnualSavings', () => { expect(savings.percent).toBe(0); expect(savings.discount).toBe(0); }); + + test('returns zero savings when annual price equals monthly total', () => { + const plan = { + amount: 1200, + billingCycle: 'year' as const, + billingMonths: 12, + monthlyPrice: 100, + }; + const savings = getAnnualSavings(plan); + expect(savings.amount).toBe(0); + expect(savings.percent).toBe(0); + }); + + test('returns zero for zero amount plan', () => { + const plan = { + amount: 0, + billingCycle: 'year' as const, + billingMonths: 12, + monthlyPrice: 0, + }; + const savings = getAnnualSavings(plan); + expect(savings.amount).toBe(0); + expect(savings.percent).toBe(0); + expect(savings.discount).toBe(0); + }); }); +// ─── getBillingOptions (expanded) ─────────────────────────────────── + describe('getBillingOptions', () => { test('removes month counts that would use annual billing', () => { const options = getBillingOptions({ @@ -72,4 +289,73 @@ describe('getBillingOptions', () => { JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8, 9, 12]), ); }); + + test('always includes annual option as last item', () => { + const options = getBillingOptions({ + annualPrice: 2400, + monthlyPriceFactor: 8, + }); + + const last = options.at(-1)!; + expect(last.billingCycle).toBe('year'); + expect(last.value).toBe(12); + }); + + test('all monthly options have billingCycle month', () => { + const options = getBillingOptions({ + annualPrice: 800, + monthlyPriceFactor: 8, + }); + + for (const opt of options.slice(0, -1)) { + expect(opt.billingCycle).toBe('month'); + } + }); + + test('options are ordered by value ascending', () => { + const options = getBillingOptions({ + annualPrice: 800, + monthlyPriceFactor: 8, + }); + + for (let i = 1; i < options.length; i++) { + expect(options[i].value).toBeGreaterThan(options[i - 1].value); + } + }); + + test('handles custom annualBillingMonths', () => { + const options = getBillingOptions({ + annualBillingMonths: 6, + annualPrice: 600, + monthlyPriceFactor: 6, + }); + + // monthlyPrice = 600/6 = 100; months 1-5 stay monthly, month 6 switches to annual + const values = options.map((o) => o.value); + expect(values).toContain(1); + expect(values).toContain(5); + // Month 6 switches to annual (6*100 = 600 = annualPrice), so value is 12 (ANNUAL_BILLING_MONTHS) + expect(values).not.toContain(6); + expect(options.at(-1)?.billingCycle).toBe('year'); + expect(options.at(-1)?.value).toBe(12); + }); + + test('every option includes all BillingPlan fields', () => { + const options = getBillingOptions({ + annualPrice: 800, + monthlyPriceFactor: 8, + }); + + for (const opt of options) { + expect(typeof opt.requestedMonths).toBe('number'); + expect(typeof opt.billingMonths).toBe('number'); + expect(typeof opt.billingCycle).toBe('string'); + expect(typeof opt.amount).toBe('number'); + expect(typeof opt.annualPrice).toBe('number'); + expect(typeof opt.monthlyPrice).toBe('number'); + expect(typeof opt.monthlyPriceFactor).toBe('number'); + expect(typeof opt.switchedToAnnual).toBe('boolean'); + expect(typeof opt.value).toBe('number'); + } + }); }); diff --git a/src/utils/helper.test.ts b/src/utils/helper.test.ts index c5b5d7a..1afe923 100644 --- a/src/utils/helper.test.ts +++ b/src/utils/helper.test.ts @@ -2,12 +2,21 @@ import { describe, expect, test, mock } from 'bun:test'; import { MAX_RECENT_APP_COUNT, RECENT_APP_STORAGE_KEY, + cn, + getManageAppDrawerCollapsed, + getManageAppDrawerPlacement, getRecentAppIds, isExpVersion, isPasswordValid, + patchSearchParams, + promiseAny, rememberRecentApp, + setManageAppDrawerCollapsed, + setManageAppDrawerPlacement, } from './helper'; +// ─── isPasswordValid ──────────────────────────────────────────────── + describe('isPasswordValid', () => { test('should return true for valid passwords', () => { expect(isPasswordValid('Passw0rd')).toBe(true); @@ -36,8 +45,181 @@ describe('isPasswordValid', () => { expect(isPasswordValid('lower123')).toBe(false); expect(isPasswordValid('noupper!')).toBe(false); }); + + test('should accept boundary length 6', () => { + expect(isPasswordValid('Abc123')).toBe(true); + }); + + test('should accept boundary length 16', () => { + expect(isPasswordValid('Abcdefghij123456')).toBe(true); + }); + + test('should reject length 5', () => { + expect(isPasswordValid('Ab123')).toBe(false); + }); + + test('should reject length 17', () => { + expect(isPasswordValid('Abcdefghij1234567')).toBe(false); + }); + + test('should return false for empty string', () => { + expect(isPasswordValid('')).toBe(false); + }); + + test('should accept passwords with special characters', () => { + expect(isPasswordValid('Ab1!@#$')).toBe(true); + }); + + test('should accept uppercase-only with digits', () => { + expect(isPasswordValid('ABCDEF1')).toBe(true); + }); +}); + +// ─── cn ───────────────────────────────────────────────────────────── + +describe('cn', () => { + test('joins class names with space', () => { + expect(cn('a', 'b', 'c')).toBe('a b c'); + }); + + test('filters out undefined values', () => { + expect(cn('a', undefined, 'b', undefined)).toBe('a b'); + }); + + test('returns empty string for all undefined', () => { + expect(cn(undefined, undefined)).toBe(''); + }); + + test('returns empty string for no arguments', () => { + expect(cn()).toBe(''); + }); + + test('handles single class name', () => { + expect(cn('only')).toBe('only'); + }); + + test('filters empty string arguments (falsy)', () => { + expect(cn('', 'a')).toBe('a'); + }); }); +// ─── promiseAny ───────────────────────────────────────────────────── + +describe('promiseAny', () => { + test('resolves with the first fulfilled promise', async () => { + const result = await promiseAny([ + Promise.reject('err1'), + Promise.resolve('winner'), + Promise.resolve('late'), + ]); + expect(result).toBe('winner'); + }); + + test('resolves when any promise resolves even if others reject', async () => { + const result = await promiseAny([ + Promise.reject(new Error('fail')), + Promise.resolve(42), + ]); + expect(result).toBe(42); + }); + + test('rejects when all promises reject', async () => { + await expect( + promiseAny([ + Promise.reject('err1'), + Promise.reject('err2'), + Promise.reject('err3'), + ]), + ).rejects.toThrow('All promises were rejected'); + }); + + test('rejects for empty array', async () => { + const p = promiseAny([]); + const result = Promise.race([ + p, + new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)), + ]); + expect(await result).toBe('timeout'); + }); + + test('resolves with first even if slow promises also resolve', async () => { + const fast = new Promise((resolve) => setTimeout(() => resolve('fast'), 10)); + const slow = new Promise((resolve) => + setTimeout(() => resolve('slow'), 100), + ); + const result = await promiseAny([slow, fast]); + expect(result).toBe('fast'); + }); +}); + +// ─── patchSearchParams ────────────────────────────────────────────── + +describe('patchSearchParams', () => { + test('sets new params', () => { + let current = new URLSearchParams(''); + const setter = mock((fn: any) => { + current = fn(current); + }); + + patchSearchParams(setter, { foo: 'bar', baz: 'qux' }); + expect(current.get('foo')).toBe('bar'); + expect(current.get('baz')).toBe('qux'); + }); + + test('deletes params when value is null', () => { + let current = new URLSearchParams('foo=bar&baz=qux'); + const setter = mock((fn: any) => { + current = fn(current); + }); + + patchSearchParams(setter, { foo: null }); + expect(current.get('foo')).toBeNull(); + expect(current.get('baz')).toBe('qux'); + }); + + test('deletes params when value is undefined', () => { + let current = new URLSearchParams('key=val'); + const setter = mock((fn: any) => { + current = fn(current); + }); + + patchSearchParams(setter, { key: undefined }); + expect(current.get('key')).toBeNull(); + }); + + test('overwrites existing params', () => { + let current = new URLSearchParams('page=1'); + const setter = mock((fn: any) => { + current = fn(current); + }); + + patchSearchParams(setter, { page: '5' }); + expect(current.get('page')).toBe('5'); + }); + + test('passes default navigateOptions to setter', () => { + const setter = mock((fn: any, opts: any) => { + expect(opts).toEqual({ replace: true }); + }); + + patchSearchParams(setter, { x: '1' }); + expect(setter).toHaveBeenCalled(); + }); + + test('supports custom navigateOptions', () => { + let current = new URLSearchParams(''); + const customOpts = { replace: false }; + const setter = mock((fn: any, opts: any) => { + current = fn(current); + expect(opts).toBe(customOpts); + }); + + patchSearchParams(setter, { a: 'b' }, customOpts); + }); +}); + +// ─── isExpVersion (expanded) ──────────────────────────────────────── + describe('isExpVersion', () => { test('should return false when config is null', () => { expect(isExpVersion(null, '1.0.0')).toBe(false); @@ -71,8 +253,24 @@ describe('isExpVersion', () => { test('should return false when rollout is greater than 100', () => { expect(isExpVersion({ rollout: { '1.0.0': 110 } }, '1.0.0')).toBe(false); }); + + test('should return true for rollout 1 (boundary)', () => { + expect(isExpVersion({ rollout: { '1.0.0': 1 } }, '1.0.0')).toBe(true); + }); + + test('should return true for rollout 99 (boundary)', () => { + expect(isExpVersion({ rollout: { '1.0.0': 99 } }, '1.0.0')).toBe(true); + }); + + test('should not be affected by other versions in rollout', () => { + const config = { rollout: { '1.0.0': 50, '2.0.0': 100 } }; + expect(isExpVersion(config, '1.0.0')).toBe(true); + expect(isExpVersion(config, '2.0.0')).toBe(false); + }); }); +// ─── isValidExternalUrl (expanded) ────────────────────────────────── + describe('isValidExternalUrl', () => { const { isValidExternalUrl } = require('./helper'); @@ -106,8 +304,28 @@ describe('isValidExternalUrl', () => { test('should return false for javascript uris', () => { expect(isValidExternalUrl('javascript:alert(1)')).toBe(false); }); + + test('should return false for empty string', () => { + expect(isValidExternalUrl('')).toBe(false); + }); + + test('should return false for ftp protocol', () => { + expect(isValidExternalUrl('ftp://react-native.cn/file')).toBe(false); + }); + + test('should return false for domain that is a suffix but not subdomain', () => { + expect(isValidExternalUrl('https://xreact-native.cn/path')).toBe(false); + }); + + test('should handle URLs with query and fragment', () => { + expect( + isValidExternalUrl('https://react-native.cn/path?a=b#section'), + ).toBe(true); + }); }); +// ─── RecentAppIds (expanded) ──────────────────────────────────────── + describe('RecentAppIds', () => { const originalWindow = (global as any).window; @@ -214,4 +432,214 @@ describe('RecentAppIds', () => { (global as any).window = originalWindow; }); + + test('rememberRecentApp should handle empty localStorage', () => { + let storage: Record = {}; + const mockStorage = { + getItem: mock((key: string) => storage[key] ?? null), + setItem: mock((key: string, value: string) => { + storage[key] = value; + }), + }; + (global as any).window = { localStorage: mockStorage }; + + const result = rememberRecentApp(1); + expect(result).toEqual([1]); + + (global as any).window = originalWindow; + }); + + test('getRecentAppIds should return empty array for empty localStorage', () => { + const mockStorage = { + getItem: mock(() => null), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getRecentAppIds()).toEqual([]); + + (global as any).window = originalWindow; + }); +}); + +// ─── ManageAppDrawerPlacement ─────────────────────────────────────── + +describe('ManageAppDrawerPlacement', () => { + const originalWindow = (global as any).window; + + test('returns left by default when no value stored', () => { + const mockStorage = { + getItem: mock(() => null), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getManageAppDrawerPlacement()).toBe('left'); + + (global as any).window = originalWindow; + }); + + test('returns left when window is undefined', () => { + (global as any).window = undefined; + expect(getManageAppDrawerPlacement()).toBe('left'); + (global as any).window = originalWindow; + }); + + test('returns left for invalid stored value', () => { + const mockStorage = { + getItem: mock(() => 'bottom'), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getManageAppDrawerPlacement()).toBe('left'); + + (global as any).window = originalWindow; + }); + + test('returns right when stored', () => { + const mockStorage = { + getItem: mock(() => 'right'), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getManageAppDrawerPlacement()).toBe('right'); + + (global as any).window = originalWindow; + }); + + test('returns hidden when stored', () => { + const mockStorage = { + getItem: mock(() => 'hidden'), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getManageAppDrawerPlacement()).toBe('hidden'); + + (global as any).window = originalWindow; + }); + + test('setManageAppDrawerPlacement stores value and dispatches event', () => { + const mockStorage = { + setItem: mock(() => {}), + }; + const mockDispatchEvent = mock(() => {}); + (global as any).window = { + localStorage: mockStorage, + dispatchEvent: mockDispatchEvent, + }; + + setManageAppDrawerPlacement('right'); + + expect(mockStorage.setItem).toHaveBeenCalledWith( + 'pushy_manage_app_drawer_placement', + 'right', + ); + expect(mockDispatchEvent).toHaveBeenCalled(); + + (global as any).window = originalWindow; + }); + + test('setManageAppDrawerPlacement does nothing when window is undefined', () => { + (global as any).window = undefined; + setManageAppDrawerPlacement('left'); + (global as any).window = originalWindow; + }); +}); + +// ─── ManageAppDrawerCollapsed ─────────────────────────────────────── + +describe('ManageAppDrawerCollapsed', () => { + const originalWindow = (global as any).window; + + test('returns false by default when no value stored', () => { + const mockStorage = { + getItem: mock(() => null), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getManageAppDrawerCollapsed()).toBe(false); + + (global as any).window = originalWindow; + }); + + test('returns false when window is undefined', () => { + (global as any).window = undefined; + expect(getManageAppDrawerCollapsed()).toBe(false); + (global as any).window = originalWindow; + }); + + test('returns true when stored as "1"', () => { + const mockStorage = { + getItem: mock(() => '1'), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getManageAppDrawerCollapsed()).toBe(true); + + (global as any).window = originalWindow; + }); + + test('returns false when stored as "0"', () => { + const mockStorage = { + getItem: mock(() => '0'), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getManageAppDrawerCollapsed()).toBe(false); + + (global as any).window = originalWindow; + }); + + test('returns false for unexpected stored values', () => { + const mockStorage = { + getItem: mock(() => 'yes'), + }; + (global as any).window = { localStorage: mockStorage }; + + expect(getManageAppDrawerCollapsed()).toBe(false); + + (global as any).window = originalWindow; + }); + + test('setManageAppDrawerCollapsed stores "1" for true', () => { + const mockStorage = { + setItem: mock(() => {}), + }; + (global as any).window = { + localStorage: mockStorage, + dispatchEvent: mock(() => {}), + }; + + setManageAppDrawerCollapsed(true); + + expect(mockStorage.setItem).toHaveBeenCalledWith( + 'pushy_manage_app_drawer_collapsed', + '1', + ); + + (global as any).window = originalWindow; + }); + + test('setManageAppDrawerCollapsed stores "0" for false', () => { + const mockStorage = { + setItem: mock(() => {}), + }; + (global as any).window = { + localStorage: mockStorage, + dispatchEvent: mock(() => {}), + }; + + setManageAppDrawerCollapsed(false); + + expect(mockStorage.setItem).toHaveBeenCalledWith( + 'pushy_manage_app_drawer_collapsed', + '0', + ); + + (global as any).window = originalWindow; + }); + + test('setManageAppDrawerCollapsed does nothing when window is undefined', () => { + (global as any).window = undefined; + setManageAppDrawerCollapsed(true); + (global as any).window = originalWindow; + }); }); diff --git a/src/utils/hooks.test.tsx b/src/utils/hooks.test.tsx index 18b0b7d..55c5fa6 100644 --- a/src/utils/hooks.test.tsx +++ b/src/utils/hooks.test.tsx @@ -7,7 +7,165 @@ import { test, } from 'bun:test'; import { act, cleanup, renderHook } from '@testing-library/react'; -import { useLocalStorageCooldown } from './hooks'; +import { getPackageTimestampWarnings, useLocalStorageCooldown } from './hooks'; + +// ─── getPackageTimestampWarnings ───────────────────────────────────── + +describe('getPackageTimestampWarnings', () => { + const makePackage = (id: number, name: string, buildTime?: string) => + ({ + id, + name, + hash: `hash-${id}`, + buildTime, + }) as unknown as import('@/types').Package; + + test('returns empty map when dict is undefined', () => { + const result = getPackageTimestampWarnings({ + dict: undefined, + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(0); + }); + + test('returns empty map when dict is empty', () => { + const result = getPackageTimestampWarnings({ + dict: [], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(0); + }); + + test('returns empty map when packages is empty', () => { + const result = getPackageTimestampWarnings({ + dict: ['packageVersion_buildTime\x1fcom.app_2024-06-01'], + packages: [], + }); + expect(result.size).toBe(0); + }); + + test('returns empty map when no metric entries match prefix', () => { + const result = getPackageTimestampWarnings({ + dict: ['someOtherMetric_value', 'anotherPrefix_123'], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(0); + }); + + test('returns empty map when all entries match current buildTime', () => { + const result = getPackageTimestampWarnings({ + dict: ['packageVersion_buildTime\x1fcom.app_2024-01-01'], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(0); + }); + + test('detects warning when buildTime differs', () => { + const result = getPackageTimestampWarnings({ + dict: ['packageVersion_buildTime\x1fcom.app_2024-06-01'], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(1); + expect(result.get(1)).toEqual(['2024-06-01']); + }); + + test('ignores entries with "unknown" timestamp', () => { + const result = getPackageTimestampWarnings({ + dict: ['packageVersion_buildTime\x1fcom.app_unknown'], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(0); + }); + + test('ignores entries with "0" timestamp', () => { + const result = getPackageTimestampWarnings({ + dict: ['packageVersion_buildTime\x1fcom.app_0'], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(0); + }); + + test('handles multiple packages with different warnings', () => { + const result = getPackageTimestampWarnings({ + dict: [ + 'packageVersion_buildTime\x1fcom.app1_2024-06-01', + 'packageVersion_buildTime\x1fcom.app2_2024-07-01', + ], + packages: [ + makePackage(1, 'com.app1', '2024-01-01'), + makePackage(2, 'com.app2', '2024-02-01'), + ], + }); + expect(result.size).toBe(2); + expect(result.get(1)).toEqual(['2024-06-01']); + expect(result.get(2)).toEqual(['2024-07-01']); + }); + + test('collects multiple different timestamps for same package', () => { + const result = getPackageTimestampWarnings({ + dict: [ + 'packageVersion_buildTime\x1fcom.app_2024-03-01', + 'packageVersion_buildTime\x1fcom.app_2024-06-01', + ], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(1); + expect(result.get(1)).toEqual(['2024-03-01', '2024-06-01']); + }); + + test('deduplicates same timestamps', () => { + const result = getPackageTimestampWarnings({ + dict: [ + 'packageVersion_buildTime\x1fcom.app_2024-06-01', + 'packageVersion_buildTime\x1fcom.app_2024-06-01', + ], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(1); + expect(result.get(1)).toEqual(['2024-06-01']); + }); + + test('handles package with no current buildTime (undefined)', () => { + const result = getPackageTimestampWarnings({ + dict: ['packageVersion_buildTime\x1fcom.app_2024-06-01'], + packages: [makePackage(1, 'com.app', undefined)], + }); + expect(result.size).toBe(1); + expect(result.get(1)).toEqual(['2024-06-01']); + }); + + test('handles package names that contain underscores', () => { + const result = getPackageTimestampWarnings({ + dict: ['packageVersion_buildTime\x1fcom.my_app_2024-06-01'], + packages: [makePackage(1, 'com.my_app', '2024-01-01')], + }); + expect(result.size).toBe(1); + expect(result.get(1)).toEqual(['2024-06-01']); + }); + + test('timestamps are sorted in output', () => { + const result = getPackageTimestampWarnings({ + dict: [ + 'packageVersion_buildTime\x1fcom.app_2024-12-01', + 'packageVersion_buildTime\x1fcom.app_2024-03-01', + 'packageVersion_buildTime\x1fcom.app_2024-06-01', + ], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + const timestamps = result.get(1)!; + expect(timestamps).toEqual(['2024-03-01', '2024-06-01', '2024-12-01']); + }); + + test('metric value with no content after prefix is ignored', () => { + const result = getPackageTimestampWarnings({ + dict: ['packageVersion_buildTime\x1f'], + packages: [makePackage(1, 'com.app', '2024-01-01')], + }); + expect(result.size).toBe(0); + }); +}); + +// ─── useLocalStorageCooldown ──────────────────────────────────────── describe('useLocalStorageCooldown', () => { const STORAGE_KEY = 'test_cooldown'; @@ -35,7 +193,7 @@ describe('useLocalStorageCooldown', () => { }); test('should initialize correctly when cooldown is already active in localStorage', () => { - window.localStorage.setItem(STORAGE_KEY, String(1000000)); // Started right now + window.localStorage.setItem(STORAGE_KEY, String(1000000)); const { result } = renderHook(() => useLocalStorageCooldown({ @@ -45,11 +203,10 @@ describe('useLocalStorageCooldown', () => { ); expect(result.current.isCoolingDown).toBe(true); - expect(result.current.remainingSeconds).toBe(5); // 5000ms = 5s + expect(result.current.remainingSeconds).toBe(5); }); test('should initialize with partial cooldown if time has elapsed', () => { - // Started 2 seconds ago (2000ms) window.localStorage.setItem(STORAGE_KEY, String(1000000 - 2000)); const { result } = renderHook(() => @@ -60,7 +217,7 @@ describe('useLocalStorageCooldown', () => { ); expect(result.current.isCoolingDown).toBe(true); - expect(result.current.remainingSeconds).toBe(3); // 3 seconds remaining + expect(result.current.remainingSeconds).toBe(3); }); test('should start cooldown and update localStorage', () => { @@ -94,7 +251,6 @@ describe('useLocalStorageCooldown', () => { expect(result.current.remainingSeconds).toBe(5); - // Advance time by 2000ms act(() => { setSystemTime(new Date(1002000)); window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY })); @@ -115,9 +271,8 @@ describe('useLocalStorageCooldown', () => { result.current.startCooldown(); }); - // Advance time past the duration act(() => { - setSystemTime(new Date(1006000)); // +6000ms + setSystemTime(new Date(1006000)); window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY })); }); @@ -125,4 +280,53 @@ describe('useLocalStorageCooldown', () => { expect(result.current.remainingSeconds).toBe(0); expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); }); + + test('should not react to storage events for other keys', () => { + window.localStorage.setItem(STORAGE_KEY, String(1000000)); + + const { result } = renderHook(() => + useLocalStorageCooldown({ + storageKey: STORAGE_KEY, + durationMs: DURATION_MS, + }), + ); + + expect(result.current.isCoolingDown).toBe(true); + + act(() => { + window.dispatchEvent( + new StorageEvent('storage', { key: 'other_key' }), + ); + }); + + expect(result.current.isCoolingDown).toBe(true); + }); + + test('should handle invalid stored value gracefully', () => { + window.localStorage.setItem(STORAGE_KEY, 'not-a-number'); + + const { result } = renderHook(() => + useLocalStorageCooldown({ + storageKey: STORAGE_KEY, + durationMs: DURATION_MS, + }), + ); + + expect(result.current.isCoolingDown).toBe(false); + expect(result.current.remainingSeconds).toBe(0); + }); + + test('should handle negative stored value gracefully', () => { + window.localStorage.setItem(STORAGE_KEY, '-1000'); + + const { result } = renderHook(() => + useLocalStorageCooldown({ + storageKey: STORAGE_KEY, + durationMs: DURATION_MS, + }), + ); + + expect(result.current.isCoolingDown).toBe(false); + expect(result.current.remainingSeconds).toBe(0); + }); }); diff --git a/src/utils/query-keys.test.ts b/src/utils/query-keys.test.ts new file mode 100644 index 0000000..ac1c68c --- /dev/null +++ b/src/utils/query-keys.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test'; +import { versionKeys } from './query-keys'; + +describe('versionKeys', () => { + describe('byApp', () => { + test('returns correct query key for app', () => { + expect(versionKeys.byApp(1)).toEqual(['versions', 1]); + }); + + test('works with different app ids', () => { + expect(versionKeys.byApp(42)).toEqual(['versions', 42]); + expect(versionKeys.byApp(0)).toEqual(['versions', 0]); + }); + + test('returns a readonly tuple', () => { + const key = versionKeys.byApp(1); + expect(key).toHaveLength(2); + expect(key[0]).toBe('versions'); + expect(key[1]).toBe(1); + }); + }); + + describe('page', () => { + test('returns correct paged query key', () => { + expect(versionKeys.page(1, 0, 10)).toEqual([ + 'versions', + 1, + 'page', + 0, + 10, + ]); + }); + + test('handles non-zero offset', () => { + expect(versionKeys.page(5, 20, 10)).toEqual([ + 'versions', + 5, + 'page', + 20, + 10, + ]); + }); + + test('different offsets produce different keys', () => { + const key1 = versionKeys.page(1, 0, 10); + const key2 = versionKeys.page(1, 10, 10); + expect(key1).not.toEqual(key2); + }); + + test('different limits produce different keys', () => { + const key1 = versionKeys.page(1, 0, 10); + const key2 = versionKeys.page(1, 0, 20); + expect(key1).not.toEqual(key2); + }); + }); + + describe('all', () => { + test('returns correct "all" query key', () => { + expect(versionKeys.all(1)).toEqual(['versions', 1, 'all']); + }); + + test('differs from byApp key', () => { + expect(versionKeys.all(1)).not.toEqual(versionKeys.byApp(1)); + }); + + test('differs from page key', () => { + expect(versionKeys.all(1)).not.toEqual(versionKeys.page(1, 0, 10)); + }); + }); + + describe('key isolation', () => { + test('keys for different app ids are unique', () => { + const key1 = versionKeys.byApp(1); + const key2 = versionKeys.byApp(2); + expect(key1).not.toEqual(key2); + }); + + test('page keys include app id for isolation', () => { + const key1 = versionKeys.page(1, 0, 10); + const key2 = versionKeys.page(2, 0, 10); + expect(key1).not.toEqual(key2); + }); + }); +});