From 2d6b74e17f1a798059b260649a602f63fdd95330 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 28 Jul 2026 11:00:41 -0500 Subject: [PATCH 1/2] fix(locations): send venue/room ids when resyncing a room to the materializer The Resync Room button sent venue and room NAMES. The materializer's per-room route stopped accepting names in dropbox-materializer PR #24 (merged 2026-06-15), which replaced sync/materialize//// with sync/materialize//// and shipped no name-based fallback. That PR's own commit message called for a coordinated change here; it was never made. Since the materializer deployed, every click has 404'd and shown a "Not Found" dialog instead of resyncing. LocationForm already receives the room id and was discarding it to look the room's name back up, so no new data is needed. It now passes this.props.entity.id (the persisted venue, so an unsaved rename in the form cannot become the target) and the room id straight through, guarding against a venue that has never been saved (id 0). The existing action tests only asserted that postRequest was called, never the URL, which is why they stayed green through the regression. Added a test pinning the built URL, and a LocationForm test that opens the Rooms panel, clicks the real resync action and asserts the ids reach onRoomResync. Both fail if the old behavior is restored. Full suite: 154 suites, 1410 tests, green. Co-Authored-By: Claude --- .../__tests__/dropbox-sync-actions.test.js | 20 +- src/actions/dropbox-sync-actions.js | 60 ++-- .../forms/__tests__/location-form.test.js | 261 ++++++++++-------- src/components/forms/location-form.js | 20 +- src/pages/locations/edit-location-page.js | 4 +- 5 files changed, 210 insertions(+), 155 deletions(-) diff --git a/src/actions/__tests__/dropbox-sync-actions.test.js b/src/actions/__tests__/dropbox-sync-actions.test.js index a18c8e93c..ccabe90e2 100644 --- a/src/actions/__tests__/dropbox-sync-actions.test.js +++ b/src/actions/__tests__/dropbox-sync-actions.test.js @@ -381,7 +381,7 @@ describe("dropbox sync actions", () => { }); test("resyncRoom dispatches START_LOADING, RESYNC_ROOM_DISPATCHED, STOP_LOADING", async () => { - store.dispatch(DropboxSyncActions.resyncRoom("Main Venue", "Room A")); + store.dispatch(DropboxSyncActions.resyncRoom(12, 345)); await flushPromises(); const actions = store.getActions(); @@ -394,6 +394,22 @@ describe("dropbox sync actions", () => { expect(postRequest).toHaveBeenCalled(); }); + test("resyncRoom targets the materializer's integer venue/room route", async () => { + // The materializer route is ////. + // It previously took names, and sending names does not resolve at all — + // the request 404s and the resync silently never happens. The older + // assertions here only checked that postRequest was called, so they stayed + // green through that regression; pin the URL itself. + store.dispatch(DropboxSyncActions.resyncRoom(12, 345)); + await flushPromises(); + + expect(postRequest).toHaveBeenCalledTimes(1); + const url = postRequest.mock.calls[0][2]; + expect(url).toBe( + "https://test-api.example.com/api/v1/sync/materialize/1/12/345/" + ); + }); + test("getSyncConfig dispatches RECEIVE_SYNC_CONFIG with empty payload on failure", async () => { getRequest.mockImplementation(mockRequestImplReject); @@ -442,7 +458,7 @@ describe("dropbox sync actions", () => { test("resyncRoom dispatches STOP_LOADING on failure", async () => { postRequest.mockImplementation(() => mockRequestImplReject()); - store.dispatch(DropboxSyncActions.resyncRoom("Main Venue", "Room A")); + store.dispatch(DropboxSyncActions.resyncRoom(12, 345)); await flushPromises(); const actions = store.getActions(); diff --git a/src/actions/dropbox-sync-actions.js b/src/actions/dropbox-sync-actions.js index b0791857a..5dd1f7f88 100644 --- a/src/actions/dropbox-sync-actions.js +++ b/src/actions/dropbox-sync-actions.js @@ -352,38 +352,38 @@ export const rebuildSync = () => async (dispatch, getState) => { }); }; -export const resyncRoom = - (venueName, roomName) => async (dispatch, getState) => { - const { currentSummitState } = getState(); - const baseUrl = getBaseUrl(); - const summitId = currentSummitState?.currentSummit?.id; - - if (!baseUrl || !summitId) return; +// The materializer's per-room route takes integer venue/room ids +// (/api/v1/sync/materialize/{summitId}/{venueId}/{roomId}/). It previously +// took names; sending names now does not resolve at all. +export const resyncRoom = (venueId, roomId) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const baseUrl = getBaseUrl(); + const summitId = currentSummitState?.currentSummit?.id; - const accessToken = await getAccessTokenSafely(); + if (!baseUrl || !summitId) return; - dispatch(startLoading()); + const accessToken = await getAccessTokenSafely(); - const params = { - access_token: accessToken - }; + dispatch(startLoading()); - return postRequest( - null, - createAction(RESYNC_ROOM_DISPATCHED), - `${baseUrl}/api/v1/sync/materialize/${summitId}/${encodeURIComponent( - venueName - )}/${encodeURIComponent(roomName)}/`, - null, - authErrorHandler - )(params)(dispatch) - .then(() => { - dispatch(stopLoading()); - dispatch( - showSuccessMessage(T.translate("dropbox_sync.resync_dispatched")) - ); - }) - .catch(() => { - dispatch(stopLoading()); - }); + const params = { + access_token: accessToken }; + + return postRequest( + null, + createAction(RESYNC_ROOM_DISPATCHED), + `${baseUrl}/api/v1/sync/materialize/${summitId}/${venueId}/${roomId}/`, + null, + authErrorHandler + )(params)(dispatch) + .then(() => { + dispatch(stopLoading()); + dispatch( + showSuccessMessage(T.translate("dropbox_sync.resync_dispatched")) + ); + }) + .catch(() => { + dispatch(stopLoading()); + }); +}; diff --git a/src/components/forms/__tests__/location-form.test.js b/src/components/forms/__tests__/location-form.test.js index 502bfec7e..f934fe4f6 100644 --- a/src/components/forms/__tests__/location-form.test.js +++ b/src/components/forms/__tests__/location-form.test.js @@ -1,124 +1,129 @@ import React from "react"; import { render } from "@testing-library/react"; import { screen } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; import LocationForm from "../location-form"; import currentSummitMock from "../../../__mocks__/currentSummitMock"; -describe("LocationForm", () => { - beforeEach(() => { - const props = { - history: { - length: 8, - action: "POP", - location: { - pathname: "/app/summits/69/locations/781", - search: "", - hash: "", - key: "wh0sst" - } - }, - currentSummit: currentSummitMock, - allClasses: [ - { - name: "string", - short_name: "string", - description: "string", - type: ["External", "Internal"], - banners: "array", - order: "integer", - opening_hour: "integer", - closing_hour: "integer", - address_1: "string", - address_2: "string", - zip_code: "string", - city: "string", - state: "string", - country: "string", - website_url: "string", - lng: "string", - lat: "string", - display_on_site: "boolean", - details_page: "boolean", - location_message: "string", - images: "array", - class_name: "SummitVenue", - is_main: "boolean", - floors: "array", - rooms: "array" - }, - { - name: "string", - short_name: "string", - description: "string", - type: ["External", "Internal"], - banners: "array", - order: "integer", - opening_hour: "integer", - closing_hour: "integer", - address_1: "string", - address_2: "string", - zip_code: "string", - city: "string", - state: "string", - country: "string", - website_url: "string", - lng: "string", - lat: "string", - display_on_site: "boolean", - details_page: "boolean", - location_message: "string", - images: "array", - class_name: "SummitAirport", - capacity: "integer", - airport_type: ["International", "Domestic"] - } - ], - entity: { - id: 781, - name: "International Barcelona Convention Center", - short_name: "CCIB", +const buildProps = (overrides = {}) => { + const props = { + history: { + length: 8, + action: "POP", + location: { + pathname: "/app/summits/69/locations/781", + search: "", + hash: "", + key: "wh0sst" + } + }, + currentSummit: currentSummitMock, + allClasses: [ + { + name: "string", + short_name: "string", + description: "string", + type: ["External", "Internal"], + banners: "array", + order: "integer", + opening_hour: "integer", + closing_hour: "integer", + address_1: "string", + address_2: "string", + zip_code: "string", + city: "string", + state: "string", + country: "string", + website_url: "string", + lng: "string", + lat: "string", + display_on_site: "boolean", + details_page: "boolean", + location_message: "string", + images: "array", class_name: "SummitVenue", - description: "", - location_type: "Internal", - address_1: "Plaça de Willy Brandt, 11-14", - address_2: "", - zip_code: "08019", - city: "Sant Marti", - state: "Barcelona", - country: "ES", - website_url: "", - lng: "2.2193", - lat: "41.4088", - display_on_site: false, - details_page: false, - is_main: false, - location_message: "", - maps: [], - images: [], - rooms: [], - floors: [], - capacity: 0, - booking_link: "", - sold_out: false, - airport_type: "", - hotel_type: "", - created: 1762190581, - last_edited: 1762190581, - order: 48, - opening_hour: "", - closing_hour: "" + is_main: "boolean", + floors: "array", + rooms: "array" }, - errors: {}, - onSubmit: jest.fn(), - onMapUpdate: jest.fn(), - onMarkerDragged: jest.fn(), - onFloorDelete: jest.fn(), - onRoomDelete: jest.fn(), - onImageDelete: jest.fn(), - onMapDelete: jest.fn() - }; + { + name: "string", + short_name: "string", + description: "string", + type: ["External", "Internal"], + banners: "array", + order: "integer", + opening_hour: "integer", + closing_hour: "integer", + address_1: "string", + address_2: "string", + zip_code: "string", + city: "string", + state: "string", + country: "string", + website_url: "string", + lng: "string", + lat: "string", + display_on_site: "boolean", + details_page: "boolean", + location_message: "string", + images: "array", + class_name: "SummitAirport", + capacity: "integer", + airport_type: ["International", "Domestic"] + } + ], + entity: { + id: 781, + name: "International Barcelona Convention Center", + short_name: "CCIB", + class_name: "SummitVenue", + description: "", + location_type: "Internal", + address_1: "Plaça de Willy Brandt, 11-14", + address_2: "", + zip_code: "08019", + city: "Sant Marti", + state: "Barcelona", + country: "ES", + website_url: "", + lng: "2.2193", + lat: "41.4088", + display_on_site: false, + details_page: false, + is_main: false, + location_message: "", + maps: [], + images: [], + rooms: [], + floors: [], + capacity: 0, + booking_link: "", + sold_out: false, + airport_type: "", + hotel_type: "", + created: 1762190581, + last_edited: 1762190581, + order: 48, + opening_hour: "", + closing_hour: "" + }, + errors: {}, + onSubmit: jest.fn(), + onMapUpdate: jest.fn(), + onMarkerDragged: jest.fn(), + onFloorDelete: jest.fn(), + onRoomDelete: jest.fn(), + onImageDelete: jest.fn(), + onMapDelete: jest.fn() + }; - render(); + return { ...props, ...overrides }; +}; + +describe("LocationForm", () => { + beforeEach(() => { + render(); }); describe("IsMain? checkbox", () => { @@ -128,3 +133,35 @@ describe("LocationForm", () => { }); }); }); + +describe("LocationForm Resync Room action", () => { + // The materializer's per-room resync route takes integer venue/room ids. + // This form is handed the room id already; it previously looked the room's + // NAME back up and sent that, which cannot resolve against the route. + const VENUE_ID = 781; + const ROOM_ID = 5002; + + test("passes the venue id and room id, not their names", async () => { + const onRoomResync = jest.fn(); + const props = buildProps({ syncEnabled: true, onRoomResync }); + props.entity = { + ...props.entity, + id: VENUE_ID, + rooms: [{ id: ROOM_ID, name: "Room A", capacity: 10, floor_name: "" }] + }; + + const { container } = render(); + + // The Rooms panel is collapsed by default; open it the way a user would. + await userEvent.click(screen.getByText("edit_location.rooms")); + + // uicore's Table renders custom row actions as . + const resyncButton = container.querySelector( + "[data-tip='dropbox_sync.resync_tooltip']" + ); + expect(resyncButton).toBeInTheDocument(); + await userEvent.click(resyncButton); + + expect(onRoomResync).toHaveBeenCalledWith(VENUE_ID, ROOM_ID); + }); +}); diff --git a/src/components/forms/location-form.js b/src/components/forms/location-form.js index ff6e309b8..591968896 100644 --- a/src/components/forms/location-form.js +++ b/src/components/forms/location-form.js @@ -14,10 +14,10 @@ import React from "react"; import T from "i18n-react/dist/i18n-react"; import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css"; -import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown" -import CountryDropdown from "openstack-uicore-foundation/lib/components/inputs/country-dropdown" -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input" -import Table from "openstack-uicore-foundation/lib/components/table" +import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; +import CountryDropdown from "openstack-uicore-foundation/lib/components/inputs/country-dropdown"; +import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; +import Table from "openstack-uicore-foundation/lib/components/table"; import Panel from "openstack-uicore-foundation/lib/components/sections/panel"; import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3"; import { GMap } from "openstack-uicore-foundation/lib/components/google-map"; @@ -212,11 +212,13 @@ class LocationForm extends React.Component { } handleRoomResync(roomId) { - const { entity } = this.state; - const persistedVenueName = this.props.entity.name; - const room = entity.rooms.find((r) => r.id === roomId); - if (room && this.props.onRoomResync) { - this.props.onRoomResync(persistedVenueName, room.name); + // IDs, not names: the materializer's resync route takes integer + // venue/room ids. Read the venue id off props (the persisted entity), not + // state, so an unsaved rename in the form cannot be sent as the target. A + // venue that has never been saved has id 0 and no rooms to resync. + const venueId = this.props.entity.id; + if (venueId && roomId && this.props.onRoomResync) { + this.props.onRoomResync(venueId, roomId); } } diff --git a/src/pages/locations/edit-location-page.js b/src/pages/locations/edit-location-page.js index cac98a18f..07671b88d 100644 --- a/src/pages/locations/edit-location-page.js +++ b/src/pages/locations/edit-location-page.js @@ -130,8 +130,8 @@ class EditLocationPage extends React.Component { }); } - handleRoomResync(venueName, roomName) { - this.props.resyncRoom(venueName, roomName); + handleRoomResync(venueId, roomId) { + this.props.resyncRoom(venueId, roomId); } render() { From 4edbdc240a8c7da56cd08bfc1998280bd389bd16 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 31 Jul 2026 14:03:39 -0500 Subject: [PATCH 2/2] test(locations): prune and strengthen the dropbox-sync action tests Full pass over the file in response to review. Deleted three tests (a duplicate single-page summit-switch test, a supersession test strictly subsumed by the A-B-A test's nothing-was-dispatched assertion, and one whose distinctive assertions iterated an array just asserted empty). Folded three near-overlaps into siblings without losing an assertion (loading bracketing, the snackbarErrorHandler identity pin - now in the mid-fan-out test so both getRequest call sites are covered, and the resyncRoom action sequence into the URL pin). Rewrote the rebuildSync/ resyncRoom failure tests to assert the real contract - the dispatch promise settles and the POST fired - instead of a two-action sequence production never emits (authErrorHandler stops loading before uicore rejects). Replaced the media-upload disjointness test with a creator-dispatching mock across both getRequest sites; the old mock discarded the creators, so it could not detect the regression it named (verified by mutation: wiring a media-upload creator in now fails it). Filtered the inert DUMMY placeholder out of the two exact-sequence assertions. 35 tests -> 29, every survivor with a nameable regression. Co-Authored-By: Claude --- .../__tests__/dropbox-sync-actions.test.js | 203 ++++++------------ 1 file changed, 60 insertions(+), 143 deletions(-) diff --git a/src/actions/__tests__/dropbox-sync-actions.test.js b/src/actions/__tests__/dropbox-sync-actions.test.js index ccabe90e2..cde1da786 100644 --- a/src/actions/__tests__/dropbox-sync-actions.test.js +++ b/src/actions/__tests__/dropbox-sync-actions.test.js @@ -75,12 +75,12 @@ describe("dropbox sync actions", () => { store.dispatch(DropboxSyncActions.getSyncConfig()); await flushPromises(); - const actions = store.getActions(); + // DUMMY is the placeholder receive creator handed to uicore so the real + // commit flows through the summit-guarded commitDispatch — inert (no + // reducer handles it), excluded from the contract. + const actions = store.getActions().filter((a) => a.type !== "DUMMY"); expect(actions).toEqual([ { payload: {}, type: "REQUEST_SYNC_CONFIG" }, - // uicore's internal receive is a DUMMY; the real commit happens through - // the summit-guarded commitDispatch below it. - { payload: { response: {} }, type: "DUMMY" }, { payload: { response: {} }, type: "RECEIVE_SYNC_CONFIG" }, { payload: undefined, type: "STOP_LOADING" } ]); @@ -117,19 +117,18 @@ describe("dropbox sync actions", () => { ); await flushPromises(); - const actions = store.getActions(); + // DUMMY (uicore's placeholder receive) filtered — see getSyncConfig test. + const actions = store.getActions().filter((a) => a.type !== "DUMMY"); expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); // REQUEST_SYNC_CONFIG is dispatched by the thunk itself (pre-token, before // putRequest) so dropboxSyncState.loading covers the full save duration // and the Save/toggle/Rebuild disabled guards block overlapping saves. expect(actions[1]).toEqual({ payload: {}, type: "REQUEST_SYNC_CONFIG" }); - // uicore's internal receive is a DUMMY; the commit is summit-guarded. - expect(actions[2]).toEqual({ payload: { response: {} }, type: "DUMMY" }); - expect(actions[3]).toEqual({ + expect(actions[2]).toEqual({ payload: { response: {} }, type: "SYNC_CONFIG_UPDATED" }); - expect(actions[4]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + expect(actions[3]).toEqual({ payload: undefined, type: "STOP_LOADING" }); expect(putRequest).toHaveBeenCalledTimes(1); }); @@ -380,7 +379,12 @@ describe("dropbox sync actions", () => { expect(postRequest).toHaveBeenCalledTimes(1); }); - test("resyncRoom dispatches START_LOADING, RESYNC_ROOM_DISPATCHED, STOP_LOADING", async () => { + test("resyncRoom targets the materializer's integer venue/room route and dispatches the action sequence", async () => { + // The materializer route is ////. + // It previously took names, and sending names does not resolve at all — + // the request 404s and the resync silently never happens. The older + // assertions here only checked that postRequest was called, so they stayed + // green through that regression; pin the URL itself. store.dispatch(DropboxSyncActions.resyncRoom(12, 345)); await flushPromises(); @@ -391,18 +395,6 @@ describe("dropbox sync actions", () => { type: "RESYNC_ROOM_DISPATCHED" }); expect(actions[2]).toEqual({ payload: undefined, type: "STOP_LOADING" }); - expect(postRequest).toHaveBeenCalled(); - }); - - test("resyncRoom targets the materializer's integer venue/room route", async () => { - // The materializer route is ////. - // It previously took names, and sending names does not resolve at all — - // the request 404s and the resync silently never happens. The older - // assertions here only checked that postRequest was called, so they stayed - // green through that regression; pin the URL itself. - store.dispatch(DropboxSyncActions.resyncRoom(12, 345)); - await flushPromises(); - expect(postRequest).toHaveBeenCalledTimes(1); const url = postRequest.mock.calls[0][2]; expect(url).toBe( @@ -444,26 +436,30 @@ describe("dropbox sync actions", () => { expect(actions[3]).toEqual({ payload: {}, type: "SYNC_CONFIG_ERROR" }); }); - test("rebuildSync dispatches STOP_LOADING on failure", async () => { + // Both callers are fire-and-forget (location-list-page's swal .then and + // edit-location-page's bare call) — the thunk's own .catch is the only thing + // preventing an unhandled rejection when the POST fails. The overlay itself + // is not at risk: authErrorHandler dispatches stopLoading before uicore + // rejects. Pin the real contract (the promise settles), not a fabricated + // action sequence. + test("rebuildSync settles (does not reject) when the POST fails", async () => { postRequest.mockImplementation(() => mockRequestImplReject()); - store.dispatch(DropboxSyncActions.rebuildSync()); - await flushPromises(); - - const actions = store.getActions(); - expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); - expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + await expect( + store.dispatch(DropboxSyncActions.rebuildSync()) + ).resolves.toBeUndefined(); + // An early return before the POST would also resolve undefined — prove + // the rejection path actually ran. + expect(postRequest).toHaveBeenCalledTimes(1); }); - test("resyncRoom dispatches STOP_LOADING on failure", async () => { + test("resyncRoom settles (does not reject) when the POST fails", async () => { postRequest.mockImplementation(() => mockRequestImplReject()); - store.dispatch(DropboxSyncActions.resyncRoom(12, 345)); - await flushPromises(); - - const actions = store.getActions(); - expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); - expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + await expect( + store.dispatch(DropboxSyncActions.resyncRoom(12, 345)) + ).resolves.toBeUndefined(); + expect(postRequest).toHaveBeenCalledTimes(1); }); }); @@ -536,6 +532,12 @@ describe("getAllMediaUploadTypesForAllowlist", () => { .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS"); expect(receives).toHaveLength(1); expect(receives[0].payload).toEqual([{ id: 1 }, { id: 2 }]); + + // Bracketing: the run opens with the global overlay and the finally + // closes it. + const types = store.getActions().map((a) => a.type); + expect(types[0]).toBe("START_LOADING"); + expect(types[types.length - 1]).toBe("STOP_LOADING"); }); it("multi page (last_page=3, page 2 delayed to resolve AFTER page 3): exactly 3 calls, ONE RECEIVE dispatch, entries concatenated in PAGE order (SDS:919)", async () => { @@ -573,16 +575,7 @@ describe("getAllMediaUploadTypesForAllowlist", () => { expect(receives[0].payload).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); }); - it("brackets the run with global startLoading/stopLoading (happy path)", async () => { - store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); - await flushPromises(); - - const types = store.getActions().map((a) => a.type); - expect(types[0]).toBe("START_LOADING"); - expect(types[types.length - 1]).toBe("STOP_LOADING"); - }); - - it("missing/zero summit id: returns Promise.resolve() WITHOUT dispatching anything — currentSummit defaults to a truthy {id: 0} entity (current-summit-reducer DEFAULT_ENTITY), so a bare truthy check would fetch summit 0", async () => { + it("missing/zero summit id: no dispatch, no fetch, no token access — currentSummit defaults to a truthy {id: 0} entity (current-summit-reducer DEFAULT_ENTITY), so a bare truthy check would fetch summit 0", async () => { store = mockStore({ currentSummitState: { currentSummit: { id: 0 } } }); @@ -740,40 +733,6 @@ describe("getAllMediaUploadTypesForAllowlist", () => { expect(receives[0].payload).toEqual([{ id: "inv2" }]); }); - it("summit switched with NO newer invocation: the stale invocation's RECEIVE/ERROR are suppressed BUT its stopLoading still fires (seq-only loading tier) — the overlay is not stranded", async () => { - let summitId = 1; - const dispatched = []; - const mockGetState = () => ({ - currentSummitState: { currentSummit: { id: summitId } } - }); - const mockDispatch = jest.fn((action) => - typeof action === "function" - ? action(mockDispatch, mockGetState) - : dispatched.push(action) - ); - - let pageResolve; - getRequest.mockImplementation( - () => () => () => - new Promise((resolve) => { - pageResolve = resolve; - }) - ); - - const thunkFn = DropboxSyncActions.getAllMediaUploadTypesForAllowlist(); - thunkFn(mockDispatch, mockGetState); - await flushPromises(); // token resolves, page 1 pending - - summitId = 2; // switch summit — NO new invocation - - pageResolve({ response: { data: [{ id: 1 }], last_page: 1 } }); - await flushPromises(); - - const types = dispatched.map((a) => a.type); - expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS"); - expect(types).toContain("STOP_LOADING"); // isNewest=true → still fires - }); - it("superseded invocation's QUEUED fan-out pages never fire an HTTP call (count getRequest invocations) — a stale identical-key request would abort the fresh invocation's in-flight page", async () => { // last_page=12: range(2,12,1) = 11 fan-out items // pLimit(TEN): 10 start immediately (pages 2..11), page 12 queued @@ -818,53 +777,6 @@ describe("getAllMediaUploadTypesForAllowlist", () => { expect(callCount).toBe(12); // page 12 of inv 1 did NOT fire }); - it("a superseded invocation dispatches NO stopLoading after being superseded (newest-clears invariant; getRequest receives the seq-guarded dispatch, so authErrorHandler's unconditional stopLoading is also suppressed)", async () => { - let callCount = 0; - let inv1Resolve; - - getRequest.mockImplementation(() => () => () => { - callCount++; - if (callCount === 1) { - return new Promise((resolve) => { - inv1Resolve = resolve; - }); - } - return Promise.resolve({ response: { data: [], last_page: 1 } }); - }); - - store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); - await flushPromises(); // inv 1 awaiting page 1 - - store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); - await flushPromises(); // inv 2 completes - - const actionsAfterInv2 = store.getActions().length; - - inv1Resolve({ response: { data: [], last_page: 1 } }); - await flushPromises(); - - // Inv 1 dispatched no STOP_LOADING (isNewest=false) - const newActions = store.getActions().slice(actionsAfterInv2); - expect(newActions.map((a) => a.type)).not.toContain("STOP_LOADING"); - }); - - it("handler identity pin: every getRequest call is constructed with snackbarErrorHandler, and a rejected page still ends with the ERROR action (does NOT exercise snackbar delivery — the mock rejects before the handler runs)", async () => { - getRequest.mockImplementation( - () => () => () => Promise.reject(new Error("Unauthorized")) - ); - - store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); - await flushPromises(); - - expect(getRequest.mock.calls.length).toBeGreaterThan(0); - getRequest.mock.calls.forEach((call) => { - expect(call[3]).toBe(snackbarErrorHandler); - }); - - const types = store.getActions().map((a) => a.type); - expect(types).toContain("ALLOWLIST_OPTIONS_ERROR"); - }); - it("page-1 failure: REQUEST then ERROR, RECEIVE never dispatched", async () => { getRequest.mockImplementation( () => () => () => Promise.reject(new Error("network error")) @@ -897,30 +809,35 @@ describe("getAllMediaUploadTypesForAllowlist", () => { const types = store.getActions().map((a) => a.type); expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS"); expect(types).toContain("ALLOWLIST_OPTIONS_ERROR"); + + // Handler identity pin, here because this fixture constructs BOTH + // getRequest call sites (page 1 and the fan-out): each must be wired with + // snackbarErrorHandler. Does not exercise snackbar delivery — the mock + // rejects before any handler runs. + expect(getRequest.mock.calls.length).toBeGreaterThan(1); + getRequest.mock.calls.forEach((call) => { + expect(call[3]).toBe(snackbarErrorHandler); + }); }); - it("a rejected page never produces a RECEIVE whose payload is undefined or non-array", async () => { + it("dispatches NO media-upload-list action types, counting what the thunk hands uicore (SDS: the aggregator must not mutate mediaUploadListState) — the mock dispatches the passed action creators, so a media-upload creator wired at either getRequest site would land in the log", async () => { + // last_page: 2 exercises BOTH getRequest call sites (page 1 and the + // fan-out). The default mock discards the creator arguments, which would + // make this check blind to the exact regression it names. getRequest.mockImplementation( - () => () => () => Promise.reject(new Error("error")) + (requestActionCreator, receiveActionCreator) => () => (dispatch) => { + if (typeof requestActionCreator === "function") + dispatch(requestActionCreator({})); + if (typeof receiveActionCreator === "function") + dispatch(receiveActionCreator({ response: {} })); + return Promise.resolve({ response: { data: [], last_page: 2 } }); + } ); store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); await flushPromises(); - const receives = store - .getActions() - .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS"); - expect(receives).toHaveLength(0); - // Belt-and-suspenders: any accidentally-slipped-through RECEIVE must carry an array - receives.forEach((action) => { - expect(Array.isArray(action.payload)).toBe(true); - }); - }); - - it("dispatches NO media-upload-list action types (assert the dispatched type set is disjoint from media-upload-actions' constants)", async () => { - store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); - await flushPromises(); - + expect(getRequest.mock.calls.length).toBeGreaterThan(1); // fan-out ran const dispatchedTypes = new Set(store.getActions().map((a) => a.type)); const mediaUploadTypes = new Set( Object.values(MediaUploadActions).filter((v) => typeof v === "string")