jevstrudel.git / website / src / jev / listeners.test.mjs
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.

31function fetchStub(status, answer) {
32  const calls = [];
33  const fetchImpl = async (url, init) => {
34    calls.push({ url, init });
35    return new Response(typeof answer === 'string' ? answer : JSON.stringify(answer), { status });
36  };
37  return { calls, fetchImpl };
38}
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});