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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
This commit is contained in:
byrongamatos
2026-09-03 18:27:12 +02:00
co-authored by Claude Sonnet 4.6
parent c264a66e5d
commit 1033de87ce
2 changed files with 59 additions and 0 deletions
+2
View File
@@ -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';
+57
View File
@@ -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');
});
});