8eecc7c0cb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
119 lines
12 KiB
JavaScript
119 lines
12 KiB
JavaScript
// 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)');
|
|
|
|
// 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: 4000 }, () => rewards.draw(0.05, 1));
|
|
const median = draws.sort((x, y) => x - y)[2000]; eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.35], [true, true], 'the draw stays in range and its median sits low (' + median + ')');
|
|
|
|
// 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' }); eq([rv.status, /ph\.ref=hunter42/.test(rv.headers.get('set-cookie') || ''), rv.headers.get('location')], [302, true, '/'], 'a share-link visit sets the referral cookie and lands on the landing');
|
|
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); });
|