@bongos/core 1.19.536 → 1.19.538

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -169,15 +169,20 @@ provisioning.createInstance = async (_db, args) => {
169
169
  provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
170
170
  provisioning.getOpenIntent = async () => null;
171
171
  provisioning.recordEvent = async () => {};
172
- // Mirrors the real statement: `detail = detail || $n::jsonb` MERGES the
173
- // normalized answers onto what is already stored. The row the route then hands
174
- // `effectiveModules` is that merged one, so a stub that returned the pre-patch
175
- // row would hide exactly the staleness this test exists to catch.
172
+ // Mirrors the real statement: `detail = (detail || $n::jsonb) - $m::text[]` MERGES
173
+ // the patch's answers onto what is already stored and DROPS the keys it explicitly
174
+ // un-answered (task 1003577). The row the route then hands `effectiveModules` is
175
+ // that merged one, so a stub that returned the pre-patch row would hide exactly the
176
+ // staleness this test exists to catch — and one that only merged would hide the
177
+ // clear, which is the whole way back to "never answered".
176
178
  provisioning.updateInstanceDetail = async (_db, _id, patch) => {
177
179
  lastUpdatedRow = { ...detailRow };
178
180
  if (patch.description !== undefined) lastUpdatedRow.description = patch.description;
179
181
  if (patch.detail !== undefined) {
180
- lastUpdatedRow.detail = { ...(detailRow.detail || {}), ...provisioning.normalizeProjectDetail(patch.detail) };
182
+ const { set, clear } = provisioning.projectDetailPatch(patch.detail);
183
+ const next = { ...(detailRow.detail || {}), ...set };
184
+ for (const key of clear) delete next[key];
185
+ lastUpdatedRow.detail = next;
181
186
  }
182
187
  // `modules` REPLACES and is normalized on the way in — the real statement's
183
188
  // `modules = $n::jsonb` over normalizeModuleSelection (BV1.R23, task 1002342).
@@ -502,6 +507,110 @@ await ta('PATCH /detail: another builder’s project is a 404, not a module chan
502
507
  detailRow = null; lastUpdatedRow = null;
503
508
  });
504
509
 
510
+ // ── Un-answering the interview ─────────────────────────────────────────────
511
+ // Task 1003577. `effectiveModules` reads three states off a row and "nobody said"
512
+ // is one of them, but a MERGING write could only ever add an answer: `{}` merged to
513
+ // nothing and `null` was dropped by the vocabulary, so the first `team_shape` an
514
+ // owner ever sent was permanent and the bundle it moved could never move back. An
515
+ // explicit per-key `null` is now the un-answer (RFC 7396, and `description`'s own
516
+ // rule on this same route), and the read-back has to resolve as a never-answered row
517
+ // — not merely "close to one".
518
+
519
+ await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one that was never asked', async () => {
520
+ // Arrange — a game project that skipped the picker, so its modules resolve from
521
+ // the bundle and the interview answer is the only thing that can move them.
522
+ const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
523
+ const neverAnswered = effectiveModules(virgin);
524
+ assert.equal(neverAnswered.source, 'bundle', 'the fixture really is an unanswered project');
525
+
526
+ detailRow = { ...virgin };
527
+ lastUpdatedRow = null;
528
+ const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
529
+ assert.equal(answered.status, 200, answered.raw);
530
+ assert.deepEqual(answered.body.detail, { team_shape: 'solo' });
531
+ assert.notDeepEqual(answered.body.modules.selected, neverAnswered.selected,
532
+ 'the fixture is only meaningful if the answer really moved the bundle');
533
+
534
+ // Act — the owner takes it back
535
+ detailRow = lastUpdatedRow;
536
+ lastUpdatedRow = null;
537
+ const cleared = await patch('/provisioning/instances/41/detail', { detail: { team_shape: null } });
538
+
539
+ // Assert — the stored answer is GONE, not stored as null, and every derived read
540
+ // is the unanswered one again
541
+ assert.equal(cleared.status, 200, cleared.raw);
542
+ assert.ok(!('team_shape' in lastUpdatedRow.detail), 'the key left the jsonb entirely');
543
+ assert.deepEqual(cleared.body.detail, {}, 'and the read surface says the interview is unanswered');
544
+ assert.deepEqual(cleared.body.modules, neverAnswered, 'source, preset and adjustments are the never-answered ones');
545
+ assert.equal(cleared.body.modules.source, 'bundle');
546
+ assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('game').bundle, 'the UNADJUSTED preset');
547
+ assert.deepEqual(cleared.body.modules.adjustments, [], 'no rule fired, so nothing claims to have');
548
+ assert.deepEqual(cleared.body.publish, answered.body.publish, 'clearing an answer is not a publish change');
549
+ detailRow = null; lastUpdatedRow = null;
550
+ });
551
+
552
+ await ta('PATCH /detail: clearing one key leaves every other stored key alone', async () => {
553
+ // The statement drops the named key rather than replacing the column, so an answer
554
+ // the patch never mentioned survives — including one stored before it left the
555
+ // vocabulary, which `projectDetail` filters on READ and must not silently delete.
556
+ detailRow = {
557
+ id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null,
558
+ detail: { team_shape: 'community', legacy_answer: 'kept' },
559
+ };
560
+ lastUpdatedRow = null;
561
+
562
+ const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: null } });
563
+
564
+ assert.equal(res.status, 200, res.raw);
565
+ assert.deepEqual(lastUpdatedRow.detail, { legacy_answer: 'kept' }, 'only the nulled key was dropped');
566
+ assert.deepEqual(res.body.detail, {}, 'and the read still filters the survivor through the vocabulary');
567
+ detailRow = null; lastUpdatedRow = null;
568
+ });
569
+
570
+ await ta('PATCH /detail: an off-vocabulary value is still REFUSED, never mistaken for a clear', async () => {
571
+ // Only null un-answers. A typo, an empty string or any other sentinel is the
572
+ // loud-validation case badProjectDetail was written for — silently treating one as
573
+ // "clear" would erase an answer the caller was trying to set.
574
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: { team_shape: 'solo' } };
575
+ for (const value of ['enormous', '', 0, false]) {
576
+ lastUpdatedRow = null;
577
+ const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: value } });
578
+ assert.equal(res.status, 400, `${JSON.stringify(value)} → ${res.raw}`);
579
+ assert.equal(res.body.error.code, 'bad_detail');
580
+ assert.match(res.body.error.message, /solo/, 'the message names the answers it does take');
581
+ assert.equal(lastUpdatedRow, null, 'a refused write touches nothing');
582
+ }
583
+ detailRow = null; lastUpdatedRow = null;
584
+ });
585
+
586
+ await ta('POST /instances: a null answer at CREATION is accepted and stores nothing — there is nothing to un-answer yet', async () => {
587
+ // badProjectDetail is shared with the create route, so the un-answer verb must not
588
+ // become a 400 there. Nothing to clear at creation, so it simply reads as no answer.
589
+ lastCreateArgs = null; existingRow = null;
590
+
591
+ const res = await post('/provisioning/instances', {
592
+ slug: 'mercury', hosting_shape: 'standalone', type: 'game', detail: { team_shape: null },
593
+ });
594
+
595
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
596
+ assert.deepEqual(res.body.instance.detail, {}, 'the row is born unanswered, as it would be with no detail at all');
597
+ });
598
+
599
+ await ta('PATCH /detail: an EMPTY detail patch still changes nothing — a skipped form is not an erase', async () => {
600
+ // RFC 7396's own rule, and the one ADR 0240 leans on: `{}` is the shape a form
601
+ // sends when it collected no answers, so making it erase would un-answer the
602
+ // interview every time an owner saved the step without touching it.
603
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: { team_shape: 'solo' } };
604
+ lastUpdatedRow = null;
605
+
606
+ const res = await patch('/provisioning/instances/41/detail', { detail: {} });
607
+
608
+ assert.equal(res.status, 200, res.raw);
609
+ assert.deepEqual(lastUpdatedRow.detail, { team_shape: 'solo' }, 'the stored answer survives an empty patch');
610
+ assert.deepEqual(res.body.detail, { team_shape: 'solo' });
611
+ detailRow = null; lastUpdatedRow = null;
612
+ });
613
+
505
614
  server.close();
506
615
  console.log(`\n${passed} passed, ${failed} failed`);
507
616
  process.exit(failed === 0 ? 0 : 1);