Files

131 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// End-to-end on a local boot: hand-off sign-in, a mission, the embed's origin and dwell locks,
// the code, the grant, the daily cap queue. No faucet, no outbound. Expected values stated.
'use strict';
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const PORT = 8899, DIR = path.join(__dirname, '..', 'data-test');
fs.rmSync(DIR, { recursive: true, force: true });
const env = Object.assign({}, process.env, { PORT: String(PORT), DATA_DIR: DIR, HUNT_SSO_SECRET: 'x'.repeat(48), ADMIN_KEY: 'adminkey123', SIGNUPS: 'open', SITE_URL: 'http://127.0.0.1:' + PORT });
delete env.CURTAIN; delete env.OUTBOUND; delete env.HUNT_WALLET_KEY; delete env.HUNT_RPC;
const child = spawn(process.execPath, [path.join(__dirname, '..', 'server.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let fails = 0;
const eq = (a, b, m) => { const ok = JSON.stringify(a) === JSON.stringify(b); console.log((ok ? ' ok ' : ' FAIL ') + m + (ok ? '' : ' -> got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b))); if (!ok) fails++; };
const B = 'http://127.0.0.1:' + PORT;
let jar = '';
const call = async (p, opt = {}) => {
const res = await fetch(B + p, Object.assign({ redirect: 'manual' }, opt, { headers: Object.assign({ Cookie: jar, 'Content-Type': 'application/json' }, opt.headers || {}) }));
const sc = res.headers.get('set-cookie'); if (sc && /ph\.sid=/.test(sc)) jar = sc.split(';')[0];
let body = null; try { body = await res.json(); } catch (e) {}
return { status: res.status, body, headers: res.headers };
};
const admin = (p, opt = {}) => call(p, Object.assign(opt, { headers: { 'X-Admin-Key': 'adminkey123' } }));
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
await sleep(1200);
process.env.HUNT_SSO_SECRET = env.HUNT_SSO_SECRET; const sso = require('../lib/sso');
const h = await call('/health'); eq([h.body.outbound, h.body.signups, h.body.faucet, h.body.sso], [false, true, false, true], 'posture: outbound off, signups open, faucet off, sso on');
// a mission whose host is this test server
const mk = await admin('/api/admin/mission', { method: 'POST', body: JSON.stringify({ id: 'test-1', site: 'Test site', name: 'Find it', brief: 'Open the page and find your code.', url: B + '/index.html', dwell: 5, slots: 3, budget: 0 }) });
eq([mk.status, mk.body.missions.length], [200, 1], 'admin creates a mission');
// sign-in by hand-off
const bad = await call('/auth?t=nonsense'); eq(bad.status, 400, 'a bad hand-off token is refused');
const tok = sso.mint({ memberId: 42, email: 'hunter@example.com', wallet: '0x' + 'ab'.repeat(20), username: 'hunter42' });
const a = await call('/auth?t=' + tok); eq([a.status, a.headers.get('location')], [302, '/app'], 'a good hand-off signs in and lands on the board');
const again = await call('/auth?t=' + tok); eq(again.status, 400, 'the same hand-off token cannot be replayed');
const board = await call('/api/my/board'); eq([board.body.me.memberId, board.body.missions.length, board.body.missions[0].done, board.body.pool.spent], [42, 1, false, false], 'board shows the mission, not done, pool open');
// a member with no wallet gets the board (the onboarding steps) but cannot start
{ const jar0 = jar; jar = ''; const nw = sso.mint({ memberId: 43, email: 'nowallet@example.com', wallet: null, username: 'nowallet' }); await call('/auth?t=' + nw);
const st0 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-1' }) }); eq([st0.status, /wallet/i.test(st0.body.error)], [400, true], 'no wallet on the account: start is refused and says why'); jar = jar0; }
// start: token + url
const st = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-1' }) });
eq([st.status, /#ph=[a-f0-9]{32}$/.test(st.body.url)], [200, true], 'start issues a token on the mission link');
const t = st.body.token;
// the embed's locks
const wrongOrigin = await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'https://evil.example' } });
eq(wrongOrigin.status, 403, 'embed: wrong origin is refused');
const early = await (await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json();
eq(typeof early.wait, 'number', 'embed: right origin before the dwell is told to wait');
await sleep(5500);
const code = await (await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json();
eq([/^[A-F0-9]{6}$/.test(code.code), code.slot >= 0 && code.slot < 3], [true, true], 'embed: after the dwell, a 6-char code and a slot 0..2');
// claim
const wrong = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: 'ZZZZZZ' }) }); eq(wrong.status, 400, 'a wrong code is refused');
const ok = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) });
eq([ok.status, ok.body.pol >= 0.05 && ok.body.pol <= 1, ok.body.queued, ok.body.unlocked], [200, true, false, ['first', 'sweep']], 'the right code pays a drip in range, not queued, and unlocks First Find (and Full Sweep: the test has one site)');
const twice = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) }); eq(twice.status, 400, 'the same mission cannot be claimed twice');
const b2 = await call('/api/my/board'); eq([b2.body.missions[0].done, b2.body.drips.length, b2.body.drips[0].status], [true, 1, 'due'], 'board: done, one drip due (faucet off, so it waits)');
// missions reset with the Central day: a find dated yesterday no longer blocks the mission today
{ const fs3 = require('fs'); const pf = path.join(DIR, 'payouts.json'); const all = JSON.parse(fs3.readFileSync(pf, 'utf8')); const yd = new Date(Date.now() - 86400000).toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); all[0].day = yd; fs3.writeFileSync(pf, JSON.stringify(all));
const b2b = await call('/api/my/board'); eq(b2b.body.missions[0].done, false, 'yesterday’s find does not mark the mission done today');
const again2 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-1' }) }); eq(again2.status, 200, 'the mission can be started again the next day');
all[0].day = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); fs3.writeFileSync(pf, JSON.stringify(all)); }
// the daily pool: once today's committed POL reaches the cap, the board says so and new starts are
// refused until the next Central midnight (Marty, 2026-09-19)
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ dailyCapPol: 0.01 }) });
await admin('/api/admin/mission', { method: 'POST', body: JSON.stringify({ id: 'test-2', site: 'Test site', name: 'Second', brief: 'x', url: B + '/index.html', dwell: 5 }) });
const bp = await call('/api/my/board'); const ct = t => new Date(t).toLocaleTimeString('en-US', { timeZone: 'America/Chicago', hour12: false });
eq([bp.body.pool.spent, bp.body.pool.resetsAt > Date.now(), bp.body.pool.resetsAt < Date.now() + 26 * 3600000, /^(00|24):00/.test(ct(bp.body.pool.resetsAt))], [true, true, true, true], 'board: pool spent, reset is the next Central midnight');
const s2 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); eq([s2.status, s2.body.spent], [400, true], 'a start after the pool is spent is refused');
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ dailyCapPol: 20 }) });
const s3 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); eq(s3.status, 200, 'with room in the pool the start is accepted again');
// the draw is weighted low
const rewards = require('../lib/rewards'); const draws = Array.from({ length: 6000 }, () => rewards.draw(0.05, 1, 3));
const median = draws.sort((x, y) => x - y)[3000]; const mean = draws.reduce((a, b) => a + b, 0) / draws.length;
eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.12, mean < 0.16, draws.some(d => d > 0.5)], [true, true, true, true], 'the k=3 draw stays in range, median ~0.09, mean ~0.13, a big drip still happens (' + median.toFixed(3) + '/' + mean.toFixed(3) + ')');
// three missions a day: with the limit set to 1, the second start of the day is refused and the board says so
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ missionsPerDay: 1 }) });
const bdl = await call('/api/my/board'); eq([bdl.body.daily.limit, bdl.body.daily.done, bdl.body.daily.left], [1, 1, 0], 'board: daily limit, done and left');
const sdl = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); eq([sdl.status, sdl.body.dailyDone, /for today/.test(sdl.body.error)], [400, true, true], 'past the daily limit a start is refused with the midnight message');
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ missionsPerDay: 3 }) });
const h2 = await call('/health'); eq([h2.body.live, h2.body.liveAt], [true, null], 'no HUNT_LIVE_AT: live now');
// social: the find is on the leaderboard with a badge, the board carries rank and a share link,
// and a share link visit sets the referral cookie that turns sign-ups into that member's IAP join link
const lb = await call('/api/leaders?period=all'); eq([lb.status, lb.body.rows.length >= 1, lb.body.rows[0].memberId, lb.body.rows[0].finds >= 1, lb.body.rows[0].badges.length >= 1], [200, true, 42, true, true], 'leaderboard: hunter 42 leads all time with a badge');
const bd = await call('/api/badges'); eq([bd.body.badges.length, bd.body.badges[0].art], [7, '/badges/badge-first.jpg'], 'seven badges are defined, each with artwork');
// badge cards: the board lists every card with lock state and share URLs; the share page and the
// stamped image exist only for badges the hunter holds
const bc = await call('/api/my/board'); const fc = bc.body.badgeCards.find(x => x.id === 'first'), tc = bc.body.badgeCards.find(x => x.id === 'tracker');
eq([bc.body.badgeCards.length, fc.earned, fc.page, tc.earned, tc.page], [7, true, B + '/b/hunter42/first', false, null], 'board: badge cards with lock state and share page');
const sp = await fetch(B + '/b/hunter42/first'); const spb = await sp.text(); eq([sp.status, /og:image" content="[^"]*\/badge-img\/hunter42\/first\.jpg"/.test(spb), /unlocked/.test(spb)], [200, true, true], 'share page serves with the stamped card as its OG image');
const sp404 = await fetch(B + '/b/hunter42/tracker'); eq(sp404.status, 404, 'no share page for a badge not held');
const bi = await fetch(B + '/badge-img/hunter42/first.jpg'); eq([bi.status, bi.headers.get('content-type')], [200, 'image/jpeg'], 'stamped badge image serves as JPEG');
const bi404 = await fetch(B + '/badge-img/nobody/first.jpg'); eq(bi404.status, 404, 'no card for an unknown hunter');
// weekly prizes: below the minimum nobody wins; with the minimum at 1 the leader gets 3 POL as a due prize drip
// that does not count as a find or against the pool, and the week cannot be awarded twice
const social = require('../lib/social'); const thisMonday = social.weekOf(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }));
const pz0 = await (await fetch(B + '/api/prizes')).json(); eq([pz0.rules.pol, pz0.rules.minFinds, pz0.week], [[3, 2, 1], 10, thisMonday], 'prize rules default to 3/2/1 POL, 10 finds, this Monday');
const aw0 = await admin('/api/admin/prizes/award', { method: 'POST', body: JSON.stringify({ week: thisMonday }) }); eq([aw0.status, aw0.body.winners.length], [200, 0], 'below the minimum finds nobody is awarded');
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ weeklyMinFinds: 1 }) });
// the week above is now recorded as awarded with no winners; award a fresh key by hand is refused for non-Mondays
const bad2 = await admin('/api/admin/prizes/award', { method: 'POST', body: JSON.stringify({ week: '2026-01-01' }) }); eq(bad2.status, 400, 'a non-Monday week key is refused');
const twiceP = await admin('/api/admin/prizes/award', { method: 'POST', body: JSON.stringify({ week: thisMonday }) }); eq(twiceP.body.skipped, 'already awarded', 'a week is never awarded twice');
{ const fs2 = require('fs'); const pf = path.join(DIR, 'prizes.json'); fs2.writeFileSync(pf, '[]'); }
const aw1 = await admin('/api/admin/prizes/award', { method: 'POST', body: JSON.stringify({ week: thisMonday }) });
eq([aw1.status, aw1.body.winners.length, aw1.body.winners[0].memberId, aw1.body.winners[0].prizePol], [200, 1, 42, 3], 'with the minimum at 1, hunter 42 wins 3 POL');
const b4 = await call('/api/my/board'); const pr = b4.body.drips.find(d => d.site === 'Weekly prize #1');
eq([!!pr, pr && pr.status, pr && pr.pol, b4.body.badges.some(x => x.id === 'top'), b4.body.rank.all.finds], [true, 'due', 3, true, 1], 'the prize is a due drip on the board, Top Hunter badge on, finds unchanged');
const lb2 = await call('/api/leaders?period=all'); eq(lb2.body.rows[0].finds, 1, 'the prize drip is not a find on the leaderboard');
const b3 = await call('/api/my/board'); eq([b3.body.badges.some(b => b.id === 'first'), b3.body.rank.all.rank, b3.body.share.link], [true, 1, B + '/?r=hunter42'], 'board: First Find badge, rank #1, share link carries the username');
const rv = await fetch(B + '/?r=hunter42', { redirect: 'manual' }); const rvb = await rv.text(); eq([rv.status, /ph\.ref=hunter42/.test(rv.headers.get('set-cookie') || ''), /og:url" content="https:\/\/polhunter\.com\/\?r=hunter42"/.test(rvb)], [200, true, true], 'a share-link visit serves the landing in place (no redirect: Facebook keeps the name), sets the referral cookie, and og:url carries the ref');
const cf = await (await fetch(B + '/api/config', { headers: { Cookie: 'ph.ref=hunter42' } })).json(); eq([cf.ref, cf.joinUrl], ['hunter42', 'https://instantadpay.com/join/hunter42?from=polhunter'], 'with the cookie, sign-ups go to that member\u2019s IAP join link');
const badRef = await fetch(B + '/?r=<script>', { redirect: 'manual' }); eq(/ph\.ref/.test(badRef.headers.get('set-cookie') || ''), false, 'a malformed ref sets no cookie');
for (const pg of ['/leaders', '/promo']) { const r = await fetch(B + pg); eq([r.status, /<title>/.test(await r.text())], [200, true], pg + ' serves'); }
// nothing outward: no telegram env, no faucet env
const led = await call('/api/ledger'); eq(led.body.totals.paid, 0, 'nothing has been paid, because no faucet is configured');
console.log(fails ? '\nFAILURES: ' + fails : '\nALL POLHUNTER CHECKS PASS');
child.kill(); fs.rmSync(DIR, { recursive: true, force: true }); process.exit(fails ? 1 : 0);
})().catch(e => { console.error('CRASH', e); child.kill(); process.exit(1); });