From 1033de87ce8e332b8f12fd933ec162f3e7f51036 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Thu, 3 Sep 2026 18:27:12 +0200 Subject: [PATCH] fix(career): gen check after error-branch json + closeBook invalidates in-flight (Creed F1b/F2b) F1b (MEDIUM): the error path (non-ok response) lacked a gen check after res.json() completed. A stale 404's json() could finish after a newer booking had incremented _ppBookGen, and the error handler would still revert the newer pref to 'any'. Fix: `if (gen !== _ppBookGen) return;` immediately after the error-branch `await res.json().catch(...)`. Test: json() side-effect bumps _ppBookGen (simulating a new booking racing in), verifies pref stays 'standard'. F2b (LOW): closeBook() dismissed the poster but left _ppBookGen unchanged, so a still-pending successful bookGig response could land after dismissal and reopen the overlay / repopulate _ppGigProposal. Fix: `++_ppBookGen` in closeBook(). Test: pending request fires, gen bumped (simulating closeBook), response resolves, asserts _ppGigProposal stays null. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj --- plugins/career/screen.js | 2 ++ tests/js/career_gig_tuning.test.js | 57 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 78e1530..5960efd 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -780,6 +780,7 @@ function closeBook() { _ppBook = null; _ppGigProposal = null; // a dismissed poster is a dismissed booking + ++_ppBookGen; // invalidate any in-flight bookGig request const overlay = $('pp-overlay'); if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; } if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' && @@ -1138,6 +1139,7 @@ if (gen !== _ppBookGen) return; // stale response — a newer request supersedes this one if (!res.ok) { const err = await res.json().catch(() => ({})); + if (gen !== _ppBookGen) return; // stale — superseded while awaiting error json() if (res.status === 404) { // No-match 404: revert tuning pref to 'any', re-render poster to match _ppGigTuningPref = 'any'; diff --git a/tests/js/career_gig_tuning.test.js b/tests/js/career_gig_tuning.test.js index b9193c7..7931166 100644 --- a/tests/js/career_gig_tuning.test.js +++ b/tests/js/career_gig_tuning.test.js @@ -274,4 +274,61 @@ describe('career-gig-tuning bookGig', () => { const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx); assert.equal(pref, 'any', '404 must revert pref to any'); }); + + test('F1b: stale 404 json completing after newer booking must not revert newer pref', async () => { + // Failure input: A 404 response is received (first gen check passes), then res.json() + // is awaited. A new booking fires while json() is pending (increments _ppBookGen). + // The error branch MUST re-check gen after json() and NOT revert pref to 'any'. + // + // We simulate the race by having json() bump _ppBookGen synchronously (equivalent to + // a new bookGig call arriving at exactly that moment) before returning a resolved value. + // After the await on json()'s resolved Promise, gen !== _ppBookGen → should bail. + const ctx = makeBookCtx(); + + ctx.fetch = async () => ({ + ok: false, + status: 404, + json: () => { + // Simulate: a new booking fires while json() is in progress + vm.runInContext( + 'window.__careerPassportTest.setBookGen(window.__careerPassportTest.getBookGen() + 1);', + ctx + ); + vm.runInContext(`window.__careerPassportTest.setTuningPref('standard');`, ctx); + return Promise.resolve({ detail: 'No drop songs.' }); + }, + }); + + vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx); + await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx); + + const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx); + assert.equal(pref, 'standard', 'stale 404 json must not revert newer pref to any'); + }); + + test('F2: closeBook() invalidates in-flight request — overlay stays closed', async () => { + // Failure input: user opens poster, a booking request is in flight (pending), + // user closes the poster via closeBook. Without _ppBookGen increment in closeBook, + // the pending response resolves, repopulates _ppGigProposal, and reopens the overlay. + const ctx = makeBookCtx(); + let resolvePending; + ctx.fetch = () => new Promise(r => { resolvePending = r; }); + + // Fire a booking — stays pending + vm.runInContext(`window.__careerPassportTest.setTuningPref('any');`, ctx); + const pending = vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx); + + // User closes the poster — must invalidate the in-flight request + // closeBook() is not in the test seam; simulate by incrementing gen directly + // (equivalent to what closeBook does with ++_ppBookGen) + vm.runInContext(`window.__careerPassportTest.setBookGen(window.__careerPassportTest.getBookGen() + 1);`, ctx); + + // Now resolve the pending request with a valid payload + resolvePending({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'a.sloppak', tuning_name: 'E Standard' }], tuning_pref: 'any' }) }); + await pending; + + // Proposal must remain null — the response was discarded + const proposal = vm.runInContext(`window.__careerPassportTest.getProposal()`, ctx); + assert.equal(proposal, null, 'closeBook-invalidated request must not repopulate _ppGigProposal'); + }); });