Skip to content

Commit c194f0b

Browse files
committed
test: add unit tests and mocked api tests
1 parent 09f9c78 commit c194f0b

14 files changed

Lines changed: 1106 additions & 0 deletions

bunfig.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[test]
2+
preload = ["./test-setup.ts"]

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"scripts": {
55
"dev": "bun run --watch src/index.ts",
66
"start": "bun run src/index.ts",
7+
"test": "bun test",
78
"lint": "biome check",
89
"lint:fix": "biome check --write",
910
"format": "biome format --write"

src/lib/cache.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { beforeEach, describe, expect, test } from 'bun:test'
2+
import { cacheStats, clearCache, loadFromCache, makeCacheKey, saveToCache } from './cache'
3+
4+
const baseParams = {
5+
artist: 'Taylor Swift',
6+
song: 'Shake It Off',
7+
timestamps: false,
8+
sequence: null,
9+
fast: false,
10+
source: null,
11+
}
12+
13+
beforeEach(() => {
14+
clearCache()
15+
})
16+
17+
describe('makeCacheKey', () => {
18+
test('same params → same key', async () => {
19+
const a = await makeCacheKey(baseParams)
20+
const b = await makeCacheKey(baseParams)
21+
expect(a).toBe(b)
22+
})
23+
24+
test('different artist → different key', async () => {
25+
const a = await makeCacheKey(baseParams)
26+
const b = await makeCacheKey({ ...baseParams, artist: 'Ed Sheeran' })
27+
expect(a).not.toBe(b)
28+
})
29+
30+
test('different song → different key', async () => {
31+
const a = await makeCacheKey(baseParams)
32+
const b = await makeCacheKey({ ...baseParams, song: 'Blank Space' })
33+
expect(a).not.toBe(b)
34+
})
35+
36+
test('artist case-insensitive (normalised to lower)', async () => {
37+
const a = await makeCacheKey({ ...baseParams, artist: 'taylor swift' })
38+
const b = await makeCacheKey({ ...baseParams, artist: 'TAYLOR SWIFT' })
39+
expect(a).toBe(b)
40+
})
41+
42+
test('timestamps flag changes key', async () => {
43+
const a = await makeCacheKey({ ...baseParams, timestamps: false })
44+
const b = await makeCacheKey({ ...baseParams, timestamps: true })
45+
expect(a).not.toBe(b)
46+
})
47+
48+
test('fast flag changes key', async () => {
49+
const a = await makeCacheKey({ ...baseParams, fast: false })
50+
const b = await makeCacheKey({ ...baseParams, fast: true })
51+
expect(a).not.toBe(b)
52+
})
53+
54+
test('returns 64-char hex SHA-256', async () => {
55+
const key = await makeCacheKey(baseParams)
56+
expect(key).toMatch(/^[0-9a-f]{64}$/)
57+
})
58+
})
59+
60+
describe('saveToCache / loadFromCache', () => {
61+
test('save then load returns same value', async () => {
62+
const key = await makeCacheKey(baseParams)
63+
const payload = { data: { lyrics: 'test lyrics' } }
64+
saveToCache(key, payload)
65+
expect(loadFromCache(key)).toEqual(payload)
66+
})
67+
68+
test('missing key returns null', async () => {
69+
expect(loadFromCache('nonexistent')).toBeNull()
70+
})
71+
72+
test('overwrite updates value', async () => {
73+
const key = await makeCacheKey(baseParams)
74+
saveToCache(key, { v: 1 })
75+
saveToCache(key, { v: 2 })
76+
expect(loadFromCache(key)).toEqual({ v: 2 })
77+
})
78+
})
79+
80+
describe('clearCache', () => {
81+
test('returns count of removed entries', async () => {
82+
const k1 = await makeCacheKey(baseParams)
83+
const k2 = await makeCacheKey({ ...baseParams, artist: 'Ed Sheeran' })
84+
saveToCache(k1, {})
85+
saveToCache(k2, {})
86+
const { removed } = clearCache()
87+
expect(removed).toBe(2)
88+
})
89+
90+
test('after clear, load returns null', async () => {
91+
const key = await makeCacheKey(baseParams)
92+
saveToCache(key, { data: 'x' })
93+
clearCache()
94+
expect(loadFromCache(key)).toBeNull()
95+
})
96+
97+
test('empty cache removed is 0', () => {
98+
const { removed } = clearCache()
99+
expect(removed).toBe(0)
100+
})
101+
})
102+
103+
describe('cacheStats', () => {
104+
test('cache_keys reflects current size', async () => {
105+
expect(cacheStats().cache_keys).toBe(0)
106+
const key = await makeCacheKey(baseParams)
107+
saveToCache(key, {})
108+
expect(cacheStats().cache_keys).toBe(1)
109+
})
110+
111+
test('version is v2', () => {
112+
expect(cacheStats().version).toBe('v2')
113+
})
114+
115+
test('ttl_seconds is a positive number', () => {
116+
expect(cacheStats().ttl_seconds).toBeGreaterThan(0)
117+
})
118+
})

src/lib/similarity.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { sequenceMatcherRatio } from './similarity'
3+
4+
describe('sequenceMatcherRatio', () => {
5+
test('both empty → 1.0', () => {
6+
expect(sequenceMatcherRatio('', '')).toBe(1.0)
7+
})
8+
9+
test('one empty → 0.0', () => {
10+
expect(sequenceMatcherRatio('hello', '')).toBe(0.0)
11+
expect(sequenceMatcherRatio('', 'hello')).toBe(0.0)
12+
})
13+
14+
test('identical strings → 1.0', () => {
15+
expect(sequenceMatcherRatio('hello', 'hello')).toBe(1.0)
16+
expect(sequenceMatcherRatio('shake it off', 'shake it off')).toBe(1.0)
17+
})
18+
19+
test('completely different → 0.0', () => {
20+
expect(sequenceMatcherRatio('abc', 'xyz')).toBe(0.0)
21+
})
22+
23+
test('partial overlap is between 0 and 1', () => {
24+
const r = sequenceMatcherRatio('hello world', 'hello earth')
25+
expect(r).toBeGreaterThan(0)
26+
expect(r).toBeLessThan(1.0)
27+
})
28+
29+
test('symmetry: f(a,b) === f(b,a)', () => {
30+
expect(sequenceMatcherRatio('taylor swift', 'swift taylor')).toBe(
31+
sequenceMatcherRatio('swift taylor', 'taylor swift'),
32+
)
33+
})
34+
35+
test('single char match', () => {
36+
expect(sequenceMatcherRatio('a', 'a')).toBe(1.0)
37+
expect(sequenceMatcherRatio('a', 'b')).toBe(0.0)
38+
})
39+
40+
test('substring scores high', () => {
41+
const r = sequenceMatcherRatio('shake', 'shake it off')
42+
expect(r).toBeGreaterThan(0.5)
43+
})
44+
})

src/lib/validator.test.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { validateLyricsMatch } from './validator'
3+
4+
const result = (artist: string, title: string) => ({ artist, title })
5+
6+
describe('validateLyricsMatch', () => {
7+
describe('exact matches', () => {
8+
test('exact artist + song → valid', () => {
9+
const r = validateLyricsMatch(
10+
'Taylor Swift',
11+
'Shake It Off',
12+
result('Taylor Swift', 'Shake It Off'),
13+
)
14+
expect(r.valid).toBe(true)
15+
expect(r.artist_match).toBeCloseTo(1.0, 2)
16+
expect(r.song_match).toBeCloseTo(1.0, 2)
17+
expect(r.script_mismatch).toBe(false)
18+
})
19+
20+
test('case insensitive match → valid', () => {
21+
const r = validateLyricsMatch(
22+
'taylor swift',
23+
'shake it off',
24+
result('TAYLOR SWIFT', 'SHAKE IT OFF'),
25+
)
26+
expect(r.valid).toBe(true)
27+
})
28+
})
29+
30+
describe('no-metadata fallbacks', () => {
31+
test('no title in result → trusting fetcher', () => {
32+
const r = validateLyricsMatch('Artist', 'Song', { artist: 'Artist' })
33+
expect(r.valid).toBe(true)
34+
expect(r.reason).toContain('trusting fetcher')
35+
})
36+
37+
test('no artist metadata → song-only match', () => {
38+
const r = validateLyricsMatch('Taylor Swift', 'Shake It Off', { title: 'Shake It Off' })
39+
expect(r.valid).toBe(true)
40+
expect(r.reason).toContain('No artist metadata')
41+
})
42+
})
43+
44+
describe('cross-script', () => {
45+
// normalizeString strips non-Latin chars via /[^\w\s]/gu so Korean/Arabic title
46+
// becomes empty → hits "trusting fetcher" path (valid: true, script_mismatch: false)
47+
test('CJK title stripped by normalizeString → trusting fetcher fallback', () => {
48+
const r = validateLyricsMatch('BTS', 'Dynamite', { artist: 'BTS', title: '다이너마이트' })
49+
expect(r.valid).toBe(true)
50+
expect(r.reason).toContain('trusting fetcher')
51+
expect(r.script_mismatch).toBe(false)
52+
})
53+
54+
test('same script (both Latin) → no script_mismatch', () => {
55+
const r = validateLyricsMatch(
56+
'Taylor Swift',
57+
'Shake It Off',
58+
result('Taylor Swift', 'Shake It Off'),
59+
)
60+
expect(r.script_mismatch).toBe(false)
61+
})
62+
})
63+
64+
describe('mismatches', () => {
65+
test('wrong artist → invalid', () => {
66+
const r = validateLyricsMatch(
67+
'Taylor Swift',
68+
'Shake It Off',
69+
result('Ed Sheeran', 'Shake It Off'),
70+
)
71+
expect(r.valid).toBe(false)
72+
expect(r.reason).toContain('artist score')
73+
})
74+
75+
test('wrong song → invalid', () => {
76+
const r = validateLyricsMatch(
77+
'Taylor Swift',
78+
'Shake It Off',
79+
result('Taylor Swift', 'Blank Space'),
80+
)
81+
expect(r.valid).toBe(false)
82+
expect(r.reason).toContain('song score')
83+
})
84+
85+
test('both wrong → reason mentions both', () => {
86+
const r = validateLyricsMatch(
87+
'Taylor Swift',
88+
'Shake It Off',
89+
result('Ed Sheeran', 'Blank Space'),
90+
)
91+
expect(r.valid).toBe(false)
92+
expect(r.reason).toContain('artist score')
93+
expect(r.reason).toContain('song score')
94+
})
95+
})
96+
97+
describe('artist matching strategies', () => {
98+
test('featured artist in returned song title → valid', () => {
99+
const r = validateLyricsMatch('Nicki Minaj', 'Monster', {
100+
artist: 'Kanye West',
101+
title: 'Monster featuring Nicki Minaj',
102+
})
103+
expect(r.valid).toBe(true)
104+
})
105+
106+
test('feat. in requested artist, exact match on primary → valid', () => {
107+
const r = validateLyricsMatch('Kanye West feat. Jay-Z', 'Otis', result('Kanye West', 'Otis'))
108+
expect(r.valid).toBe(true)
109+
})
110+
111+
test('artist array field → valid', () => {
112+
const r = validateLyricsMatch('Taylor Swift', 'Shake It Off', {
113+
artists: ['Taylor Swift'],
114+
title: 'Shake It Off',
115+
})
116+
expect(r.valid).toBe(true)
117+
})
118+
119+
test('trackArtist field → valid', () => {
120+
const r = validateLyricsMatch('Taylor Swift', 'Shake It Off', {
121+
trackArtist: 'Taylor Swift',
122+
trackName: 'Shake It Off',
123+
})
124+
expect(r.valid).toBe(true)
125+
})
126+
})
127+
128+
describe('extension suffixes', () => {
129+
test('remix suffix → valid', () => {
130+
const r = validateLyricsMatch(
131+
'Taylor Swift',
132+
'Shake It Off',
133+
result('Taylor Swift', 'Shake It Off Remix'),
134+
)
135+
expect(r.valid).toBe(true)
136+
})
137+
138+
test('live suffix → valid', () => {
139+
const r = validateLyricsMatch('Adele', 'Hello', result('Adele', 'Hello Live'))
140+
expect(r.valid).toBe(true)
141+
})
142+
143+
test('acoustic suffix → valid', () => {
144+
const r = validateLyricsMatch(
145+
'Ed Sheeran',
146+
'Shape of You',
147+
result('Ed Sheeran', 'Shape of You Acoustic'),
148+
)
149+
expect(r.valid).toBe(true)
150+
})
151+
})
152+
153+
describe('adaptive threshold', () => {
154+
test('lenient threshold accepts near-miss', () => {
155+
const strict = validateLyricsMatch(
156+
'Radiohead',
157+
'Karma Police',
158+
result('Radio Head', 'Karma Police'),
159+
0.99,
160+
)
161+
const lenient = validateLyricsMatch(
162+
'Radiohead',
163+
'Karma Police',
164+
result('Radio Head', 'Karma Police'),
165+
0.5,
166+
)
167+
if (!strict.valid) {
168+
expect(lenient.valid).toBe(true)
169+
}
170+
})
171+
172+
test('short song name uses lower adaptive threshold', () => {
173+
const r = validateLyricsMatch('Jay-Z', 'Run', result('Jay-Z', 'Run'))
174+
expect(r.valid).toBe(true)
175+
})
176+
})
177+
178+
describe('returned fields', () => {
179+
test('returned_artists is normalized array', () => {
180+
const r = validateLyricsMatch(
181+
'Taylor Swift',
182+
'Shake It Off',
183+
result('Taylor Swift', 'Shake It Off'),
184+
)
185+
expect(Array.isArray(r.returned_artists)).toBe(true)
186+
expect(r.returned_artists).toContain('taylor swift')
187+
})
188+
189+
test('returned_song is normalized', () => {
190+
const r = validateLyricsMatch(
191+
'Taylor Swift',
192+
'Shake It Off',
193+
result('Taylor Swift', 'Shake It Off'),
194+
)
195+
expect(r.returned_song).toBe('shake it off')
196+
})
197+
198+
test('scores are rounded to 3 decimal places', () => {
199+
const r = validateLyricsMatch(
200+
'Taylor Swift',
201+
'Shake It Off',
202+
result('Taylor Swift', 'Shake It Off'),
203+
)
204+
expect(r.artist_match * 1000).toBe(Math.round(r.artist_match * 1000))
205+
expect(r.song_match * 1000).toBe(Math.round(r.song_match * 1000))
206+
})
207+
})
208+
})

0 commit comments

Comments
 (0)