From 2f90e1a97f66566cdcec38585756ef9a9c280e91 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 16 Sep 2026 17:40:03 -0500 Subject: [PATCH] QA pass on the member profile gate: stable button ids, completion opens the dashboard tab, local devCode, 47-test E2E suite Findings fixed: the gate's buttons had no stable ids (fragile to test and maintain), and finishing the gate left the member on the pitch tab where the profile card and Messages are not visible, so completion now opens the Position Dashboard tab. /api/public/profile/email-start returns devCode outside production so the flow is testable locally, matching the InstantAdPay pattern. qa/profiles-unit.mjs (28 assertions) and qa/gate-e2e.mjs (47 assertions, real sessions, real UI) with a README. All pass. Co-Authored-By: Claude Opus 5 (1M context) --- profiles.js | 5 +- public/my.html | 2 +- public/profile-gate.js | 19 +++--- qa/README.md | 25 ++++++++ qa/gate-e2e.mjs | 128 +++++++++++++++++++++++++++++++++++++++++ qa/profiles-unit.mjs | 62 ++++++++++++++++++++ server.js | 1 + 7 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 qa/README.md create mode 100644 qa/gate-e2e.mjs create mode 100644 qa/profiles-unit.mjs diff --git a/profiles.js b/profiles.js index 0cb3a55..abffbbd 100644 --- a/profiles.js +++ b/profiles.js @@ -168,5 +168,8 @@ function coverage() { } function adminList() { return Object.values(db.byId).map(pub).sort((a, b) => a.id - b.id); } -module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, +// local testing only: the server exposes this outside production, never on the live site +function peekCode(id) { const st = codes.get(norm(id)); return st ? st.code : null; } + +module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE }; diff --git a/public/my.html b/public/my.html index cf2bf64..2fd125f 100644 --- a/public/my.html +++ b/public/my.html @@ -31,5 +31,5 @@
All figures are read live from the RM Circle smart contract on Polygon and are historical facts, not a promise of future results. Participation involves cryptocurrency and smart-contract risk. Never use funds you cannot afford to lose.
- + diff --git a/public/profile-gate.js b/public/profile-gate.js index 3fd69ec..ae1fa72 100644 --- a/public/profile-gate.js +++ b/public/profile-gate.js @@ -38,6 +38,7 @@ if (editable) { // editing an existing profile can be abandoned; the first-time gate can not var x = el('button', 'position:absolute;top:10px;right:12px;z-index:2;width:34px;height:34px;border-radius:50%;border:none;' + 'cursor:pointer;background:rgba(255,255,255,.08);color:#fff;font-size:20px;line-height:1;font-weight:700;', '×'); + x.id = 'pgClose'; x.setAttribute('aria-label', 'Close'); x.addEventListener('click', close); card.appendChild(x); @@ -68,9 +69,10 @@ 'background:rgba(255,255,255,.04);color:#fff;font-size:16px;margin-bottom:10px;'; return i; } - function button(label) { + function button(label, id) { var b = el('button', 'width:100%;padding:13px 16px;border-radius:11px;border:none;cursor:pointer;font-size:15px;font-weight:700;' + 'background:linear-gradient(180deg,' + GOLD + ',#b8932f);color:#1a1205;', label); + if (id) b.id = id; return b; } function errLine() { return el('p', 'margin:0 0 10px;font-size:13px;color:#ff8b8b;display:none;'); } @@ -83,7 +85,7 @@ var err = errLine(); card.appendChild(err); var i = input('pgUser', 'e.g. ' + (state.suggest || 'member' + state.id), (state.profile && state.profile.username) || ''); card.appendChild(i); - var b = button('Save and continue'); + var b = button('Save and continue', 'pgUserSave'); card.appendChild(b); var go = async function () { err.style.display = 'none'; b.disabled = true; b.textContent = 'Saving…'; @@ -112,14 +114,14 @@ var err = errLine(); card.appendChild(err); var i = input('pgEmail', 'you@example.com', prefill, 'email'); card.appendChild(i); - var b = button(prefill ? 'Send me the code' : 'Send me a code'); + var b = button(prefill ? 'Send me the code' : 'Send me a code', 'pgMailSend'); card.appendChild(b); var codeWrap = el('div', 'display:none;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.1);'); var sentNote = note('', TEAL); codeWrap.appendChild(sentNote); var ci = input('pgCode', '6-digit code', ''); ci.inputMode = 'numeric'; ci.maxLength = 6; codeWrap.appendChild(ci); - var cb = button('Confirm and finish'); + var cb = button('Confirm and finish', 'pgCodeConfirm'); codeWrap.appendChild(cb); var again = el('p', 'margin:10px 0 0;font-size:12.5px;color:#7f93a8;text-align:center;cursor:pointer;', 'Wrong address? Change it and send a new code.'); again.addEventListener('click', function () { codeWrap.style.display = 'none'; b.disabled = false; b.textContent = 'Send me a code'; i.focus(); }); @@ -159,14 +161,15 @@ '
✅
' + '

You are all set, @' + esc(state.profile.username) + '

' + '

Your leader can reach you now, and every payout to your wallet sends you a note.

'; - var b = button('Open my dashboard'); - b.addEventListener('click', close); + var b = button('Open my dashboard', 'pgDone'); + b.addEventListener('click', function () { close(true); }); card.appendChild(b); - setTimeout(close, 2600); + setTimeout(function () { close(true); }, 2600); } - function close() { + function close(openDash) { if (back && back.parentNode) back.parentNode.removeChild(back); back = null; + if (openDash) { try { var b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); } catch (e) {} } if (resolveDone) { var r = resolveDone; resolveDone = null; r((state && state.profile) || null); } } function next() { diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000..ecc43da --- /dev/null +++ b/qa/README.md @@ -0,0 +1,25 @@ +# RM Circle QA + +## Member profile gate + +``` +# unit: profiles.js in isolation (seeding, username rules, code flow, persistence) +node qa/profiles-unit.mjs + +# end to end: the real UI with real inbox sessions +TMP=/d/tmp/rmc-e2e-data && rm -rf $TMP && mkdir -p $TMP +ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $TMP/config.json +echo '{}' > $TMP/sponsors.json +echo '{"49":{"email":"seeded49@example.com","ts":"x"}}' > $TMP/member-alerts.json +node -e "const m=require('./messages.js'); m.init({dataDir:process.argv[1],chain:require('./chain.js'),isProd:false}); require('fs').writeFileSync(process.argv[1]+'/tokens.txt', m.mintSession(21)+'\n'+m.mintSession(49));" $TMP +PORT=3399 DATA_DIR=$TMP ADMIN_PASSWORD=localtest node server.js & +LOCAL=http://127.0.0.1:3399 TOKEN=$(sed -n 1p $TMP/tokens.txt) TOKEN2=$(sed -n 2p $TMP/tokens.txt) node qa/gate-e2e.mjs +``` + +Covers: the gate fires on the owner's own page only, cannot be dismissed (Escape, backdrop, no close +button), username and email validation, the code flow including a wrong code, completion landing on +the dashboard tab, the profile card and in-place editing (dismissable), the seeded email pre-filling +at step 2, visitors on every ID-keyed shared link seeing no gate and no 401, and phone-width layout. + +`devCode` is returned by `/api/public/profile/email-start` only when `NODE_ENV !== 'production'`, +which is what lets the test read the code. The live container runs with NODE_ENV=production. diff --git a/qa/gate-e2e.mjs b/qa/gate-e2e.mjs new file mode 100644 index 0000000..e43aff2 --- /dev/null +++ b/qa/gate-e2e.mjs @@ -0,0 +1,128 @@ +// End-to-end QA of the RM Circle required profile gate, driven through the real UI +// with a real inbox session. Boot: LOCAL (default :3399), session cookie in COOKIE. +import { pathToFileURL } from 'node:url'; +const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; +const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; +const B = process.env.LOCAL || 'http://127.0.0.1:3399'; +const TOKEN = process.env.TOKEN; // ctb.msid for position 21 +const TOKEN2 = process.env.TOKEN2; // ctb.msid for position 49 (already complete) +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; +const browser = await chromium.launch(); +const ctxFor = async (tok, mobile) => { + const c = await browser.newContext(mobile ? { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } : { viewport: { width: 1280, height: 950 } }); + if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]); + return c; +}; +const gateOpen = p => p.evaluate(() => !!document.querySelector('#pgCard')); +const cardText = p => p.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); + +// ---------- 1. the gate on the member's OWN page ---------- +const ctx = await ctxFor(TOKEN); +const p = await ctx.newPage(); +await p.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p.waitForTimeout(2500); +t('gate appears on the owner\'s own page', await gateOpen(p)); +t('gate names the position and step', /position #21 . step 1 of 2/i.test(await cardText(p)), await cardText(p)); + +// cannot be dismissed +await p.keyboard.press('Escape'); await p.waitForTimeout(400); +t('Escape does not dismiss the required gate', await gateOpen(p)); +await p.mouse.click(8, 8); await p.waitForTimeout(400); +t('backdrop click does not dismiss the required gate', await gateOpen(p)); +t('no close button on the required gate', !(await p.evaluate(() => !!document.querySelector('#pgClose')))); + +// username validation +const typeUser = async v => { await p.fill('#pgUser', v); await p.click('#pgUserSave'); await p.waitForTimeout(600); }; +await typeUser('ab'); +t('rejects a short username', /3 to 20/.test(await cardText(p)), await cardText(p)); +await typeUser('12345'); +t('rejects digits only', /at least one letter/.test(await cardText(p))); +await typeUser('admin'); +t('rejects a reserved name', /reserved/.test(await cardText(p))); +await typeUser('takenname'); +t('accepts a good username and moves to step 2', /step 2 of 2/i.test(await cardText(p)), await cardText(p)); + +// email step +t('step 2 explains why and promises privacy', /never shown to other members/i.test(await cardText(p))); +await p.fill('#pgEmail', 'not-an-email'); await p.click('#pgMailSend'); await p.waitForTimeout(700); +t('rejects a malformed email', /does not look right/.test(await cardText(p)), await cardText(p)); +// grab the dev code straight off the API response +let devCode = null; +p.on('response', async r => { if (r.url().includes('/profile/email-start')) { try { const j = await r.json(); if (j.devCode) devCode = j.devCode; } catch (e) {} } }); +await p.fill('#pgEmail', 'qa-member@example.com'); await p.click('#pgMailSend'); await p.waitForTimeout(1400); +t('code is sent and the code box appears', /Code sent to qa\*\*\*@example\.com/.test(await cardText(p)), await cardText(p)); +t('the reply masks the address', /qa\*\*\*@example\.com/.test(await cardText(p))); +await p.fill('#pgCode', '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(800); +t('wrong code refused', /does not match/.test(await cardText(p)), await cardText(p)); +t('dev code exposed locally for the test', !!devCode, String(devCode)); +await p.fill('#pgCode', devCode || '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(1200); +t('right code completes the profile', /all set, @takenname/i.test(await cardText(p)), await cardText(p)); +await p.waitForTimeout(3200); +t('gate closes itself after completion', !(await gateOpen(p))); + +// ---------- 2. the profile card, and editing ---------- +t('completion lands on the dashboard tab', await p.evaluate(() => { const d = document.getElementById('tabDash'); return !!d && getComputedStyle(d).display !== 'none'; })); +await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2000); +await p.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p.waitForTimeout(1800); +t('no gate on a completed profile', !(await gateOpen(p))); +const body = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +t('dashboard shows the profile card', /Your member profile/.test(body), body.slice(0, 120)); +t('card shows the username', /@takenname/.test(body)); +t('card shows the confirmed email', /qa-member@example\.com/.test(body) && /confirmed/i.test(body)); +t('Change username button present', await p.evaluate(() => !!document.querySelector('#pgEditUser'))); +t('Change email button present', await p.evaluate(() => !!document.querySelector('#pgEditMail'))); +await p.click('#pgEditUser'); await p.waitForTimeout(900); +t('edit modal opens', await gateOpen(p)); +t('edit modal HAS a close button', await p.evaluate(() => !!document.querySelector('#pgClose'))); +await p.click('#pgClose'); await p.waitForTimeout(600); +t('edit modal can be dismissed', !(await gateOpen(p))); +await p.click('#pgEditUser'); await p.waitForTimeout(800); +await p.fill('#pgUser', 'renamedqa'); +await p.click('#pgUserSave'); await p.waitForTimeout(1400); +t('rename saves and closes', !(await gateOpen(p))); +await p.waitForTimeout(1200); +t('card shows the new username', /@renamedqa/.test(await p.evaluate(() => document.body.innerText))); + +// ---------- 3. scoping: never on someone else's page ---------- +const p2 = await ctx.newPage(); +await p2.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p2.waitForTimeout(2000); +await p2.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p2.waitForTimeout(1600); +t('no gate while browsing another position', !(await gateOpen(p2))); +t('no profile card on another position\'s page', !/Your member profile/.test(await p2.evaluate(() => document.body.innerText))); +t('the other page still renders', (await p2.evaluate(() => document.body.innerText)).length > 400); + +// an INCOMPLETE member browsing someone else's page must also not be gated +const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage(); +await p3.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2600); +t('incomplete member is not gated on a teammate page', !(await gateOpen(p3))); +await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2600); +t('incomplete member IS gated on their own page', await gateOpen(p3)); +t('their gate starts at the username step', /step 1 of 2/i.test(await cardText(p3)), await cardText(p3)); +await p3.fill('#pgUser', 'seeded49'); await p3.click('#pgUserSave'); await p3.waitForTimeout(900); +t('seeded email is pre-filled at step 2', await p3.evaluate(() => { const i = document.querySelector('#pgEmail'); return i ? i.value : ''; }) === 'seeded49@example.com', await p3.evaluate(() => { const i = document.querySelector('#pgEmail'); return i ? i.value : 'no input'; })); +t('copy tells them it is already on file', /already have this one on file/i.test(await cardText(p3)), await cardText(p3)); + +// ---------- 4. visitors ---------- +const ctxAnon = await ctxFor(null); const p4 = await ctxAnon.newPage(); +const anon401 = []; +p4.on('response', r => { if (r.status() === 401 && r.url().includes('/profile')) anon401.push(r.url()); }); +for (const u of ['/my/21', '/my/49', '/join/21', '/fast-start?id=21', '/generation-pay?id=21', '/flyers?id=21']) { + await p4.goto(B + u, { waitUntil: 'domcontentloaded' }); await p4.waitForTimeout(1600); + const txt = await p4.evaluate(() => document.body.innerText); + t('visitor: ' + u + ' renders with no gate', !(await gateOpen(p4)) && txt.length > 300, 'len ' + txt.length); +} +t('visitor sees no 401 from the profile endpoint', anon401.length === 0, anon401.join(',')); + +// ---------- 5. phone width ---------- +const ctxM = await ctxFor(TOKEN2, true); const p5 = await ctxM.newPage(); +await p5.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p5.waitForTimeout(2600); +t('gate renders on a phone', await gateOpen(p5)); +const fits = await p5.evaluate(() => { const c = document.querySelector('#pgCard'); if (!c) return false; const r = c.getBoundingClientRect(); return r.width <= window.innerWidth && r.left >= 0; }); +t('gate fits the phone viewport', fits); +const noHScroll = await p5.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 2); +t('no horizontal scroll on a phone', noHScroll); + +console.log('\nPASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +await browser.close(); +process.exit(bad.length ? 1 : 0); diff --git a/qa/profiles-unit.mjs b/qa/profiles-unit.mjs new file mode 100644 index 0000000..97eae02 --- /dev/null +++ b/qa/profiles-unit.mjs @@ -0,0 +1,62 @@ +// Unit test for RM Circle profiles.js: seeding, username rules, email code flow, coverage. +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +const require = createRequire('D:/Projects/HighRisk/The RM Circle/promos/package.json'); +const DIR = 'D:/tmp/rmc-prof-data'; +fs.rmSync(DIR, { recursive: true, force: true }); fs.mkdirSync(DIR, { recursive: true }); +// two positions already gave an email for payout alerts: they must seed in, unverified +fs.writeFileSync(DIR + '/member-alerts.json', JSON.stringify({ '21': { email: 'Marty@Example.com', ts: 'x' }, '49': { email: 'b@example.com', ts: 'x' } })); +const profiles = require('D:/Projects/HighRisk/The RM Circle/promos/profiles.js'); +const sent = []; +profiles.init({ dataDir: DIR, sendEmail: (to, subj, text) => sent.push({ to, subj, text }) }); +const ok = [], bad = []; +const t = (name, cond, extra) => (cond ? ok : bad).push(name + (extra ? ' -> ' + extra : '')); + +// seeding +t('seeded 2 alert emails', profiles.coverage().withEmail === 2, JSON.stringify(profiles.coverage())); +t('seeded email is lowercased', (profiles.get(21) || {}).email === 'marty@example.com'); +t('seeded email is NOT verified', !(profiles.get(21) || {}).emailVerified); +t('seeded position is incomplete', !profiles.complete(21)); + +// username rules +t('rejects short username', !!profiles.setUsername(21, 'ab').error); +t('rejects digits-only', !!profiles.setUsername(21, '12345').error); +t('rejects reserved', !!profiles.setUsername(21, 'admin').error); +t('rejects bad chars', !!profiles.setUsername(21, 'marty bostick').error); +t('accepts good username', profiles.setUsername(21, 'MartyB').ok === true); +t('username is lowercased', (profiles.get(21) || {}).username === 'martyb'); +t('strips a leading @', profiles.setUsername(49, '@circleguy').ok && profiles.get(49).username === 'circleguy'); +t('blocks a taken username', !!profiles.setUsername(49, 'martyb').error); +t('lets the same position keep its own name', profiles.setUsername(21, 'martyb').ok === true); +t('byUsername finds it', (profiles.byUsername('@MartyB') || {}).id === 21); + +// email flow +t('rejects a bad address', !!profiles.startEmail(21, 'not-an-email').error); +const s1 = profiles.startEmail(21, 'marty@example.com'); +t('sends a code', s1.ok === true && sent.length === 1, JSON.stringify(s1)); +t('code email masks the address in the reply', (s1.to || '').includes('***')); +const code = (sent[0].subj.match(/(\d{6})/) || [])[1]; +t('subject carries a 6-digit code', !!code); +t('cooldown blocks an instant resend', !!profiles.startEmail(21, 'marty@example.com').error); +t('wrong code refused', !!profiles.verifyEmail(21, '000000').error); +t('right code verifies', profiles.verifyEmail(21, code).ok === true); +t('profile now complete', profiles.complete(21) === true); +t('contactFor reports reachable', profiles.contactFor(21).reachable === true); +t('unverified position is NOT reachable', profiles.contactFor(49).reachable === false); +t('replayed code is refused', !!profiles.verifyEmail(21, code).error); + +// one email, several positions (Triple Play) +profiles.setUsername(137, 'martyb2'); +const s2 = profiles.startEmail(137, 'marty@example.com'); +const code2 = (sent[sent.length - 1].subj.match(/(\d{6})/) || [])[1]; +profiles.verifyEmail(137, code2); +t('one email can hold several positions', JSON.stringify(profiles.positionsFor('marty@example.com')) === '[21,137]', JSON.stringify(profiles.positionsFor('marty@example.com'))); + +// persistence +const fresh = JSON.parse(fs.readFileSync(DIR + '/profiles.json', 'utf8')); +t('written to disk', fresh.byId['21'].emailVerified === true); +t('coverage counts', profiles.coverage().complete === 2, JSON.stringify(profiles.coverage())); + +console.log('PASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +process.exit(bad.length ? 1 : 0); diff --git a/server.js b/server.js index 828b947..6a6015c 100644 --- a/server.js +++ b/server.js @@ -1466,6 +1466,7 @@ async function handleApi(req,res,pathname){ if(!s)return json(res,401,{error:'Not signed in.'}); const b=await bodyJson(req).catch(()=>null); const r=profiles.startEmail(s.id,b&&b.email); + if(r.ok&&!IS_PROD&&r.devCode===undefined)r.devCode=profiles.peekCode(s.id); // local testing only; never in production return json(res,r.error?400:200,r); } if(req.method==='POST'&&pathname==='/api/public/profile/email-verify'){