1import { describe, expect, it } from 'vitest'; 2import { 3 asSong, 4 codeTitle, 5 listenerParam, 6 listenerSongs, 7 listSongs, 8 loadListenerSong, 9 postComment, 10 scoreText, 11 statusText, 12 uploadCover, 13} from './listeners.mjs'; 14 15const ID = 'AbCdEfGhIjKlMnOpQrSt_-'; 16const view = { 17 id: ID, 18 author: 'Link', 19 rev: 2, 20 title: 'Moblin Stomp', 21 description: 'a march', 22 updatedAt: 1, 23 art: { art: 0.61, artRuns: [0.6, 0.62, 0.61], criteria: { hook: 3 }, model: 'jev-1.13.0', method: 'rubric-1' }, 24 cover: { id: 'c'.repeat(22), alt: 'a moblin' }, 25 spec: '# spec', 26 code: 's("bd")', 27 revisions: [{ rev: 1, at: 1, art: 0.5 }], 28};
A fetch that records the call and answers answer with status.
40describe('listener songs in the song browser', () => { 41 it('take the shape of the site’s own songs, under an id no site song can have', () => { 42 const song = asSong(view); 43 expect(song).toMatchObject({ 44 id: `listener:${ID}`, 45 listenerId: ID, 46 author: 'Link', 47 art: { p: 0.61, model: 'jev-1.13.0', criteria: { hook: 3 } }, 48 cover: `/jev/listeners/covers/${'c'.repeat(22)}`, 49 coverAlt: 'a moblin', 50 foreignCode: 's("bd")', 51 timing: null, 52 }); 53 // its code is never `code`, which every path that plays a site song 54 // reads: a listener's plays only in the sandbox (sandbox.mjs) 55 expect(song).not.toHaveProperty('code'); 56 // a list entry has no code until the song is loaded whole 57 expect(asSong({ ...view, code: undefined, art: null, cover: null })).toMatchObject({ 58 foreignCode: null, 59 art: null, 60 cover: null, 61 }); 62 }); 63 64 it('loads a song whole and keeps it for the song view', async () => { 65 const { calls, fetchImpl } = fetchStub(200, view); 66 const song = await loadListenerSong(ID, { fetchImpl }); 67 expect(calls[0].url).toBe(`/jev/listeners/songs/${ID}`); 68 expect(listenerSongs.get(`listener:${ID}`)).toBe(song); 69 }); 70 71 it('reads ?listener= only when it is an id', () => { 72 expect(listenerParam(`?listener=${ID}&session=x`)).toBe(ID); 73 expect(listenerParam('?listener=../../x')).toBeNull(); 74 expect(listenerParam('')).toBeNull(); 75 }); 76 77 it('titles a song from its // @title line', () => { 78 expect(codeTitle('// @title Moblin Stomp\ns("bd")')).toBe('Moblin Stomp'); 79 expect(codeTitle('s("bd")')).toBe(''); 80 }); 81}); 82 83describe('calls to the Worker', () => { 84 it('post JSON, send the session cookie, and say the Worker’s own error', async () => { 85 const ok = fetchStub(201, { id: 'x', status: 'public' }); 86 await postComment('jev/dial-up', 'nice', { fetchImpl: ok.fetchImpl }); 87 expect(ok.calls[0]).toMatchObject({ 88 url: '/jev/listeners/comments', 89 init: { method: 'POST', credentials: 'same-origin', body: JSON.stringify({ song: 'jev/dial-up', body: 'nice' }) }, 90 }); 91 const refused = fetchStub(401, { error: 'sign in to publish, comment or pitch' }); 92 await expect(postComment('jev/dial-up', 'x', { fetchImpl: refused.fetchImpl })).rejects.toThrow(/sign in/); 93 // a static host without the Worker answers its own 404 page 94 const none = fetchStub(404, '<html>not found</html>'); 95 await expect(listSongs({ fetchImpl: none.fetchImpl })).rejects.toThrow(/404/); 96 }); 97 98 it('upload a cover as multipart form data', async () => { 99 const { calls, fetchImpl } = fetchStub(201, { id: 'c', status: 'pending', why: 'new', next: 0 }); 100 await uploadCover(ID, new Blob([new Uint8Array([1])], { type: 'image/png' }), 'a moblin', { fetchImpl }); 101 expect(calls[0].url).toBe(`/jev/listeners/songs/${ID}/cover`); 102 expect(calls[0].init.method).toBe('PUT'); 103 expect(calls[0].init.body.get('alt')).toBe('a moblin'); 104 expect(calls[0].init.headers).toEqual({}); 105 }); 106}); 107 108describe('what the author is told', () => { 109 const now = Date.UTC(2026, 8, 25, 12); 110 it('says public, held with Jev’s verdict, or pending with why and when', () => { 111 expect(statusText({ status: 'public' })).toBe('public'); 112 expect(statusText({ status: 'held', verdict: 'spam', confidence: 0.91 })).toBe( 113 'held: Jev says spam, 91% sure; only you can see it', 114 ); 115 expect(statusText({ status: 'pending', why: 'budget', next: now + 3600_000 }, now)).toMatch( 116 /^pending: your Jev budget for today is spent; Jev is asked again at /, 117 ); 118 expect(statusText({ status: 'pending', why: 'unreachable', next: now - 1 }, now)).toBe( 119 'pending: Jev could not be reached; Jev is asked again shortly', 120 ); 121 }); 122 123 it('says a revision’s art, or why its score is still owed', () => { 124 expect(scoreText({ status: 'public', art: 0.614 })).toBe('61% art'); 125 expect(scoreText({ status: 'public', art: null })).toBe('art: being scored'); 126 expect(scoreText({ status: 'public', art: null, score: { why: 'budget', next: now - 1 } }, now)).toBe( 127 'art pending: your Jev budget for today is spent', 128 ); 129 expect(scoreText({ status: 'held', art: null })).toBe(''); 130 }); 131});