diff --git a/app/common/util.test.mjs b/app/common/util.test.mjs new file mode 100644 index 0000000..72074ca --- /dev/null +++ b/app/common/util.test.mjs @@ -0,0 +1,200 @@ +import { describe, it, expect } from 'vitest'; +import { + getTopOfCurrentHour, + getDateParts, + getGearIcon, + normalizeSplatnetResourcePath, + deriveId, + getFestId, + getFestTeamId, + getXRankSeasonId, + jsonpathQuery, + jsonpathApply, + calculateCacheExpiry, +} from './util.mjs'; + +describe('getTopOfCurrentHour', () => { + it('zeros out minutes, seconds, and milliseconds', () => { + const date = new Date('2024-01-15T14:35:22.123Z'); + const result = getTopOfCurrentHour(date); + expect(result.getUTCMinutes()).toBe(0); + expect(result.getUTCSeconds()).toBe(0); + expect(result.getUTCMilliseconds()).toBe(0); + expect(result.getUTCHours()).toBe(14); + }); + + it('returns the mutated date object', () => { + const date = new Date('2024-01-15T14:35:22.123Z'); + const result = getTopOfCurrentHour(date); + expect(result).toBe(date); + }); + + it('uses current date when no argument', () => { + const before = new Date(); + const result = getTopOfCurrentHour(); + expect(result.getUTCMinutes()).toBe(0); + expect(result.getUTCSeconds()).toBe(0); + expect(result.getUTCMilliseconds()).toBe(0); + }); +}); + +describe('getDateParts', () => { + it('returns correct parts with leading zeros', () => { + const date = new Date('2024-03-05T08:02:09.000Z'); + const parts = getDateParts(date); + expect(parts).toEqual({ + year: 2024, + month: '03', + day: '05', + hour: '08', + minute: '02', + second: '09', + }); + }); + + it('handles double-digit values', () => { + const date = new Date('2024-12-25T23:59:45.000Z'); + const parts = getDateParts(date); + expect(parts).toEqual({ + year: 2024, + month: '12', + day: '25', + hour: '23', + minute: '59', + second: '45', + }); + }); +}); + +describe('getGearIcon', () => { + it('returns cap emoji for HeadGear', () => { + expect(getGearIcon({ gear: { __typename: 'HeadGear' } })).toBe('๐Ÿงข'); + }); + + it('returns shirt emoji for ClothingGear', () => { + expect(getGearIcon({ gear: { __typename: 'ClothingGear' } })).toBe('๐Ÿ‘•'); + }); + + it('returns shoe emoji for ShoesGear', () => { + expect(getGearIcon({ gear: { __typename: 'ShoesGear' } })).toBe('๐Ÿ‘Ÿ'); + }); + + it('returns null for unknown type', () => { + expect(getGearIcon({ gear: { __typename: 'WeaponGear' } })).toBeNull(); + }); +}); + +describe('normalizeSplatnetResourcePath', () => { + it('strips /resources/prod prefix and leading slash', () => { + const url = 'https://api.lp1.av5ja.srv.nintendo.net/resources/prod/v2/weapon_illust/12345.png'; + expect(normalizeSplatnetResourcePath(url)).toBe('v2/weapon_illust/12345.png'); + }); + + it('handles URLs without the prefix', () => { + const url = 'https://example.com/images/test.png'; + expect(normalizeSplatnetResourcePath(url)).toBe('images/test.png'); + }); + + it('strips query strings', () => { + const url = 'https://api.lp1.av5ja.srv.nintendo.net/resources/prod/v2/image.png?ver=123'; + expect(normalizeSplatnetResourcePath(url)).toBe('v2/image.png'); + }); +}); + +describe('deriveId', () => { + it('produces consistent hash for the same URL', () => { + const node = { image: { url: 'https://example.com/resources/prod/v2/test.png' }, name: 'Test' }; + const result1 = deriveId(node); + const result2 = deriveId(node); + expect(result1.__splatoon3ink_id).toBe(result2.__splatoon3ink_id); + }); + + it('produces different hashes for different URLs', () => { + const node1 = { image: { url: 'https://example.com/resources/prod/v2/a.png' } }; + const node2 = { image: { url: 'https://example.com/resources/prod/v2/b.png' } }; + expect(deriveId(node1).__splatoon3ink_id).not.toBe(deriveId(node2).__splatoon3ink_id); + }); + + it('preserves original node properties', () => { + const node = { image: { url: 'https://example.com/test.png' }, name: 'Gear', rarity: 2 }; + const result = deriveId(node); + expect(result.name).toBe('Gear'); + expect(result.rarity).toBe(2); + expect(result.image).toBe(node.image); + expect(result.__splatoon3ink_id).toBeDefined(); + }); +}); + +describe('getFestId', () => { + it('extracts ID from base64-encoded Fest string', () => { + const encoded = Buffer.from('Fest-US:12345').toString('base64'); + expect(getFestId(encoded)).toBe('12345'); + }); + + it('handles multi-region prefix', () => { + const encoded = Buffer.from('Fest-UJEA:99').toString('base64'); + expect(getFestId(encoded)).toBe('99'); + }); + + it('returns original value on non-match', () => { + expect(getFestId('not-base64-fest')).toBe('not-base64-fest'); + }); +}); + +describe('getFestTeamId', () => { + it('extracts FESTID:TEAMID from base64-encoded string', () => { + const encoded = Buffer.from('FestTeam-US:100:3').toString('base64'); + expect(getFestTeamId(encoded)).toBe('100:3'); + }); + + it('returns original value on non-match', () => { + expect(getFestTeamId('invalid')).toBe('invalid'); + }); +}); + +describe('getXRankSeasonId', () => { + it('extracts REGION-ID from base64-encoded string', () => { + const encoded = Buffer.from('XRankingSeason-US:5').toString('base64'); + expect(getXRankSeasonId(encoded)).toBe('US-5'); + }); + + it('returns original value on non-match', () => { + expect(getXRankSeasonId('invalid')).toBe('invalid'); + }); +}); + +describe('jsonpathQuery', () => { + it('returns matching values from nested objects', () => { + const data = { store: { books: [{ title: 'A' }, { title: 'B' }] } }; + const result = jsonpathQuery(data, '$.store.books[*].title'); + expect(result).toEqual(['A', 'B']); + }); + + it('returns empty array for no match', () => { + const data = { a: 1 }; + const result = jsonpathQuery(data, '$.b'); + expect(result).toEqual([]); + }); +}); + +describe('jsonpathApply', () => { + it('transforms matching values in-place', () => { + const data = { items: [{ price: 10 }, { price: 20 }] }; + jsonpathApply(data, '$.items[*].price', v => v * 2); + expect(data.items[0].price).toBe(20); + expect(data.items[1].price).toBe(40); + }); +}); + +describe('calculateCacheExpiry', () => { + it('returns timestamp = now + expiresIn - 5 minutes', () => { + const before = Date.now(); + const expiresIn = 3600; // 1 hour in seconds + const result = calculateCacheExpiry(expiresIn); + const after = Date.now(); + + const fiveMinutes = 5 * 60 * 1000; + expect(result).toBeGreaterThanOrEqual(before + expiresIn * 1000 - fiveMinutes); + expect(result).toBeLessThanOrEqual(after + expiresIn * 1000 - fiveMinutes); + }); +});