Files
furumi-web/tests/browser-statistics.cjs
T
Aleksandr Bogomiakov 4141562e33
Build and Publish / Build and Publish Docker Image (push) Successful in 9m49s
Added stat page
2026-11-12 11:52:38 +00:00

90 lines
7.4 KiB
JavaScript

// Run against a fresh disposable instance and the named disposable PostgreSQL container.
// PLAYWRIGHT_MODULE may point to an existing Playwright installation.
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const {spawnSync} = require('node:child_process');
const base = process.env.FURU_TEST_URL || 'http://127.0.0.1:3018';
(async () => {
const browser = await chromium.launch({headless:true, executablePath:process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try {
const context = await browser.newContext({viewport:{width:1440,height:1100}, ignoreHTTPSErrors:true});
const page = await context.newPage(); const errors=[];
page.on('pageerror', error => errors.push(error.message));
const anonymous = await context.request.get(`${base}/admin/v2/api/statistics`);
assert.equal(anonymous.status(),401);
await page.goto(`${base}/admin/setup`);
await page.getByLabel('Username', {exact:true}).fill('alice');
await page.getByLabel('Password', {exact:true}).fill('statistics-test-password');
await page.locator('[name=confirm_password]').fill('statistics-test-password');
await page.locator('form button[type=submit]').click();
await page.waitForURL('**/admin/v2');
await page.getByRole('button', {name:'Users',exact:true}).click();
await page.getByRole('button', {name:'Add user',exact:true}).click();
const editor = page.locator('.modal-backdrop').filter({has:page.getByRole('button',{name:'Save user',exact:true})});
await editor.getByLabel('username', {exact:true}).fill('bob');
await editor.getByLabel('password', {exact:true}).fill('statistics-test-password');
await editor.getByRole('button',{name:'Save user',exact:true}).click();
await editor.waitFor({state:'hidden'});
const sql = `INSERT INTO furumusic__listen_event
(user_id,listen_id,content_id,local_track_id,origin_device_id,started_at_ms,listened_ms,track_duration_ms,ended_reason,qualified,metadata_json,created_at)
SELECT 1,'ui-'||n,'content-one',NULL,'web-a',1793491200000+n*1000,180000,180000,'"finished"',true,
'{"title":"First song","artist_names":["Artist A"],"release_title":"Album"}'::jsonb,'2026-11-01T00:00:00Z' FROM generate_series(1,30) n;
INSERT INTO furumusic__listen_event
(user_id,listen_id,content_id,local_track_id,origin_device_id,started_at_ms,listened_ms,track_duration_ms,ended_reason,qualified,metadata_json,created_at)
VALUES (2,'ui-bob','content-one',NULL,'web-b',1793664000000,90000,180000,'"skipped"',true,
'{"title":"First song","artist_names":["Artist A"],"release_title":"Album"}','2026-11-03T00:00:00Z');`;
const seeded = spawnSync('docker',['exec','-i','furumi-statistics-test-pg','psql','-U','postgres','-d','furumi_statistics_test','-v','ON_ERROR_STOP=1'],{input:sql, encoding:'utf8'});
assert.equal(seeded.status,0,seeded.stderr);
await page.getByRole('button',{name:'Statistics',exact:true}).click();
const area = page.locator('.content').filter({has:page.getByText('Listening statistics',{exact:true})});
await area.getByRole('button',{name:'Artist A',exact:true}).first().waitFor();
const artistResponse = page.waitForResponse(r => r.url().includes('/api/statistics?') && r.url().includes('artist=Artist+A'));
await area.getByRole('button',{name:'Artist A',exact:true}).first().click();
assert.equal((await artistResponse).status(),200);
await area.getByRole('button',{name:'bob',exact:true}).first().waitFor();
const bobResponse = page.waitForResponse(r => r.url().includes('/api/statistics?') && r.url().includes('user_id=2'));
await area.getByRole('button',{name:'bob',exact:true}).first().click();
const bob = await (await bobResponse).json();
assert.equal(bob.summary.plays,1); assert.equal(bob.history.items[0].user_name,'bob');
assert.equal(bob.history.items[0].title,'First song');
const cleared = page.waitForResponse(r => r.url().includes('/api/statistics?') && !r.url().includes('user_id=') && !r.url().includes('artist='));
await area.getByRole('button',{name:'All listeners and music'}).click();
await cleared;
await area.getByText('Loading statistics…',{exact:true}).waitFor({state:'hidden'});
await page.screenshot({path:'/tmp/furumi-statistics-desktop.png',fullPage:false});
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:'/tmp/furumi-statistics-mobile.png',fullPage:false});
await page.setViewportSize({width:1440,height:1100});
const usersResponse = await context.request.get(`${base}/admin/v2/api/users`); const users = await usersResponse.json();
assert.equal(users.items.find(u=>u.username==='bob').plays,1);
const detailResponse = await context.request.get(`${base}/admin/v2/api/users/2`); const detail = await detailResponse.json();
assert.equal(detail.stats.plays,1); assert.equal(detail.recent_plays[0].title,'First song');
const next = await context.request.get(`${base}/admin/v2/api/statistics?from=all&history_page=2`);
assert.equal((await next.json()).history.items.length,6);
for (const path of ['/admin/debug','/admin/users','/admin/settings','/admin/artists','/admin/reviews','/admin/jobs']) {
assert.equal((await context.request.get(base+path)).status(),404,path);
}
assert.equal((await context.request.get(`${base}/admin/v2/api/statistics?from=invalid`)).status(),400);
const jobs = await (await context.request.get(`${base}/admin/v2/api/jobs`)).json();
const job = jobs.find(j => j.name === 'metadata_backfill');
if (job.enabled) assert.equal((await context.request.post(`${base}/admin/v2/api/jobs/${job.name}/toggle`)).status(),200);
assert.equal((await context.request.post(`${base}/admin/v2/api/jobs/${job.name}/schedule`,{data:{cron:'0 17 3 * * *'}})).status(),200);
await context.request.get(`${base}/admin/v2/api/dashboard`);
const changedJob = (await (await context.request.get(`${base}/admin/v2/api/jobs`)).json()).find(j=>j.name===job.name);
assert.equal(changedJob.enabled,false); assert.equal(changedJob.cron_expression,'0 17 3 * * *');
const ownDelete = await context.request.post(`${base}/admin/v2/api/users/manage`, {data:{action:'delete',id:1}});
assert.equal(ownDelete.status(),400);
const listenerContext = await browser.newContext({ignoreHTTPSErrors:true});
const login = await listenerContext.newPage();
await login.goto(`${base}/login`);
await login.locator('[name=username]').fill('bob');
await login.locator('[name=password]').fill('statistics-test-password');
await login.locator('form button[type=submit]').click();
await login.waitForURL(base + '/');
assert.equal((await listenerContext.request.get(`${base}/admin/v2/api/statistics`)).status(),403);
await listenerContext.close();
assert.deepEqual(errors,[]);
console.log('PASS: setup, user creation, artist → listeners → user history, counts, pagination, invalid filters, v1 removal, desktop/mobile rendering');
} finally { await browser.close(); }
})().catch(error => {console.error(error); process.exitCode=1});