diff --git a/src/actions/__tests__/dropbox-sync-actions.test.js b/src/actions/__tests__/dropbox-sync-actions.test.js index 1df140caa..d15395d1b 100644 --- a/src/actions/__tests__/dropbox-sync-actions.test.js +++ b/src/actions/__tests__/dropbox-sync-actions.test.js @@ -10,8 +10,10 @@ import { putRequest, postRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { snackbarErrorHandler } from "../base-actions"; import * as DropboxSyncActions from "../dropbox-sync-actions"; import * as methods from "../../utils/methods"; +import * as MediaUploadActions from "../media-upload-actions"; jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ __esModule: true, @@ -76,6 +78,9 @@ describe("dropbox sync actions", () => { const actions = store.getActions(); 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" } ]); @@ -106,7 +111,7 @@ describe("dropbox sync actions", () => { expect(methods.getAccessTokenSafely).not.toHaveBeenCalled(); }); - test("updateSyncConfig dispatches START_LOADING, SYNC_CONFIG_UPDATED, STOP_LOADING", async () => { + test("updateSyncConfig dispatches START_LOADING, REQUEST_SYNC_CONFIG, SYNC_CONFIG_UPDATED, STOP_LOADING", async () => { store.dispatch( DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true }) ); @@ -114,14 +119,253 @@ describe("dropbox sync actions", () => { const actions = store.getActions(); expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); - expect(actions[1]).toEqual({ + // 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({ payload: { response: {} }, type: "SYNC_CONFIG_UPDATED" }); - expect(actions[2]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + expect(actions[4]).toEqual({ payload: undefined, type: "STOP_LOADING" }); expect(putRequest).toHaveBeenCalledTimes(1); }); + test("getSyncConfig gates BEFORE the token await: REQUEST_SYNC_CONFIG is dispatched synchronously", () => { + // The mount GET must flip syncLoading immediately so the toggle/Save/ + // Rebuild guards disable mutations for the GET's whole duration — a PUT + // started during a slow token refresh could otherwise interleave with + // the GET (early flag clear, or a stale GET overwriting the PUT's config). + store.dispatch(DropboxSyncActions.getSyncConfig()); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain("REQUEST_SYNC_CONFIG"); + expect(getRequest).not.toHaveBeenCalled(); + }); + + test("updateSyncConfig gates BEFORE the token await: START_LOADING + REQUEST_SYNC_CONFIG are dispatched synchronously", () => { + // No flushPromises: a slow token refresh must not leave a window where + // Save/toggle are still enabled (dropboxSyncState.loading false) and a + // second click can start an overlapping PUT. + store.dispatch( + DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true }) + ); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain("START_LOADING"); + expect(types).toContain("REQUEST_SYNC_CONFIG"); + // The PUT itself has not started yet (token still pending). + expect(putRequest).not.toHaveBeenCalled(); + }); + + // Request-identity guard: getSyncConfig/updateSyncConfig share a sequence; + // only the newest invocation for the still-current summit may commit. + describe("sync-config request-identity guard", () => { + // Deferred mock that emulates the real uicore contract: on resolution it + // dispatches the receive action creator through the dispatch it was + // handed, THEN resolves. Without that emulation these tests would pass + // vacuously against pre-guard code, which relied on uicore dispatching + // the receive creator internally. + const deferredRequestImpl = + (register) => + (requestActionCreator, receiveActionCreator) => + () => + (dispatch) => + new Promise((resolve, reject) => { + if ( + requestActionCreator && + typeof requestActionCreator === "function" + ) + dispatch(requestActionCreator({})); + register({ + resolve: (payload) => { + if (typeof receiveActionCreator === "function") + dispatch(receiveActionCreator(payload)); + resolve(payload); + }, + reject + }); + }); + + test("stale GET response for a previous summit is NOT committed after a summit switch", async () => { + let currentState = { currentSummitState: { currentSummit: { id: 1 } } }; + const gStore = mockStore(() => currentState); + let inFlight; + getRequest.mockImplementation( + deferredRequestImpl((handle) => { + inFlight = handle; + }) + ); + + gStore.dispatch(DropboxSyncActions.getSyncConfig()); + await flushPromises(); + expect(getRequest).toHaveBeenCalledTimes(1); + + // Summit switch while the response is in flight. + currentState = { currentSummitState: { currentSummit: { id: 2 } } }; + inFlight.resolve({ + response: { summit_id: 1, dropbox_sync_enabled: true } + }); + await flushPromises(); + + // Summit 1's config must not repopulate summit 2's slice. + const types = gStore.getActions().map((a) => a.type); + expect(types).not.toContain("RECEIVE_SYNC_CONFIG"); + }); + + test("superseded GET's failure does not clear the newer GET's loading flag (identical-key abort)", async () => { + let rejectA; + let resolveB; + getRequest + .mockImplementationOnce( + () => () => () => + new Promise((_resolve, reject) => { + rejectA = reject; + }) + ) + .mockImplementationOnce( + () => () => () => + new Promise((resolve) => { + resolveB = resolve; + }) + ); + + store.dispatch(DropboxSyncActions.getSyncConfig()); // A + await flushPromises(); + store.dispatch(DropboxSyncActions.getSyncConfig()); // B supersedes A + await flushPromises(); + + // uicore aborts A when B fires with the identical token-stripped key; + // A's catch must NOT dispatch RECEIVE_SYNC_CONFIG({}) — that would + // clear loading (and wipe the config) while B is still in flight. + rejectA(new Error("aborted")); + await flushPromises(); + let types = store.getActions().map((a) => a.type); + expect(types.filter((t) => t === "RECEIVE_SYNC_CONFIG")).toHaveLength(0); + + resolveB({ response: { summit_id: 1 } }); + await flushPromises(); + types = store.getActions().map((a) => a.type); + expect(types.filter((t) => t === "RECEIVE_SYNC_CONFIG")).toHaveLength(1); + }); + + test("PUT superseded by a GET that bails on summit switch: the inherited overlay is still cleared", async () => { + // The superseded PUT's terminal dispatches are sequence-suppressed and + // the superseding GET never started the overlay — the GET's bail path + // is the only live party that can clear it. + let currentState = { currentSummitState: { currentSummit: { id: 1 } } }; + const gStore = mockStore(() => currentState); + putRequest.mockImplementation(deferredRequestImpl(() => {})); + let resolveTokenB; + methods.getAccessTokenSafely + .mockReturnValueOnce("TOKEN") // PUT A + .mockReturnValueOnce( + new Promise((resolve) => { + resolveTokenB = resolve; + }) + ); // GET B + + gStore.dispatch( + DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true }) + ); + await flushPromises(); // A past its token; PUT in flight; overlay started + gStore.dispatch(DropboxSyncActions.getSyncConfig()); // B supersedes A, token pending + // Summit switches while B awaits its token. + currentState = { currentSummitState: { currentSummit: { id: 2 } } }; + const stopsBefore = gStore + .getActions() + .filter((a) => a.type === "STOP_LOADING").length; + + resolveTokenB("TOKEN"); + await flushPromises(); // B bails (newest, summit switched) + + const stopsAfter = gStore + .getActions() + .filter((a) => a.type === "STOP_LOADING").length; + expect(stopsAfter).toBe(stopsBefore + 1); + expect(getRequest).not.toHaveBeenCalled(); // B really bailed pre-HTTP + }); + + test("superseded PUT that succeeded triggers a same-summit refetch so redux converges", async () => { + // A GET issued after the PUT was sent can be answered with the PRE-save + // snapshot; with the PUT's own commit suppressed, redux would hold + // stale config indefinitely without the convergence refetch. + const getHandles = []; + const putHandles = []; + getRequest.mockImplementation( + deferredRequestImpl((h) => getHandles.push(h)) + ); + putRequest.mockImplementation( + deferredRequestImpl((h) => putHandles.push(h)) + ); + + store.dispatch( + DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true }) + ); // PUT A + await flushPromises(); + store.dispatch(DropboxSyncActions.getSyncConfig()); // GET B supersedes A + await flushPromises(); + expect(getRequest).toHaveBeenCalledTimes(1); + + putHandles[0].resolve({ + response: { summit_id: 1, dropbox_sync_enabled: true } + }); // A succeeds server-side; its commit is suppressed + await flushPromises(); + const types = store.getActions().map((a) => a.type); + expect(types).not.toContain("SYNC_CONFIG_UPDATED"); + // The convergence refetch (GET C) fired. + expect(getRequest).toHaveBeenCalledTimes(2); + + // B answers with the PRE-save snapshot — superseded by C, not committed. + getHandles[0].resolve({ + response: { summit_id: 1, dropbox_sync_enabled: false } + }); + await flushPromises(); + expect( + store.getActions().filter((a) => a.type === "RECEIVE_SYNC_CONFIG") + ).toHaveLength(0); + + // C answers with the post-save state — the one commit that lands. + getHandles[1].resolve({ + response: { summit_id: 1, dropbox_sync_enabled: true } + }); + await flushPromises(); + const receives = store + .getActions() + .filter((a) => a.type === "RECEIVE_SYNC_CONFIG"); + expect(receives).toHaveLength(1); + expect(receives[0].payload.response.dropbox_sync_enabled).toBe(true); + }); + + test("stale PUT response is NOT committed after a summit switch (but its overlay is still cleared)", async () => { + let currentState = { currentSummitState: { currentSummit: { id: 1 } } }; + const gStore = mockStore(() => currentState); + let inFlight; + putRequest.mockImplementation( + deferredRequestImpl((handle) => { + inFlight = handle; + }) + ); + + gStore.dispatch( + DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true }) + ); + await flushPromises(); + currentState = { currentSummitState: { currentSummit: { id: 2 } } }; + inFlight.resolve({ + response: { summit_id: 1, dropbox_sync_enabled: true } + }); + await flushPromises(); + + const types = gStore.getActions().map((a) => a.type); + expect(types).not.toContain("SYNC_CONFIG_UPDATED"); + // Still the newest invocation, so the overlay it started is cleared. + expect(types).toContain("STOP_LOADING"); + }); + }); + test("rebuildSync dispatches START_LOADING, REBUILD_SYNC_DISPATCHED, STOP_LOADING", async () => { store.dispatch(DropboxSyncActions.rebuildSync()); await flushPromises(); @@ -157,10 +401,15 @@ describe("dropbox sync actions", () => { await flushPromises(); const actions = store.getActions(); - expect(actions).toEqual([{ payload: {}, type: "RECEIVE_SYNC_CONFIG" }]); + // REQUEST (pre-token) then RECEIVE({}) from the catch: loading is set and + // then cleared even when the GET fails — no stranded flag. + expect(actions).toEqual([ + { payload: {}, type: "REQUEST_SYNC_CONFIG" }, + { payload: {}, type: "RECEIVE_SYNC_CONFIG" } + ]); }); - test("updateSyncConfig dispatches STOP_LOADING on failure", async () => { + test("updateSyncConfig on failure: loading is set (REQUEST) and then cleared (SYNC_CONFIG_ERROR), no deadlock", async () => { putRequest.mockImplementation(() => mockRequestImplReject()); store.dispatch( @@ -169,8 +418,14 @@ describe("dropbox sync actions", () => { await flushPromises(); const actions = store.getActions(); + // REQUEST_SYNC_CONFIG comes from the thunk's own pre-token dispatch, so + // it fires even though the reject mock bypasses uicore's request action — + // this pins that a failed save cannot strand dropboxSyncState.loading. expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); - expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + expect(actions[1]).toEqual({ payload: {}, type: "REQUEST_SYNC_CONFIG" }); + expect(actions[2]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + // Clears the loading flag without resetting the stored syncConfig. + expect(actions[3]).toEqual({ payload: {}, type: "SYNC_CONFIG_ERROR" }); }); test("rebuildSync dispatches STOP_LOADING on failure", async () => { @@ -195,3 +450,477 @@ describe("dropbox sync actions", () => { expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" }); }); }); + +// ─── getAllMediaUploadTypesForAllowlist ─────────────────────────────────────── +describe("getAllMediaUploadTypesForAllowlist", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + let store; + + beforeEach(() => { + jest.clearAllMocks(); + store = mockStore({ + currentSummitState: { currentSummit: { id: 1 } } + }); + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + window.API_BASE_URL = "https://summit-api.example.com"; + window.DROPBOX_MATERIALIZER_API_BASE_URL = "https://test-api.example.com"; + // Default mock — individual tests override as needed + getRequest.mockImplementation( + () => () => () => + Promise.resolve({ response: { data: [], last_page: 1 } }) + ); + putRequest.mockImplementation( + (requestActionCreator, receiveActionCreator) => () => (dispatch) => { + if (requestActionCreator && typeof requestActionCreator === "function") + dispatch(requestActionCreator({})); + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: {} })); + resolve({ response: {} }); + return; + } + resolve({ response: {} }); + }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.API_BASE_URL; + delete window.DROPBOX_MATERIALIZER_API_BASE_URL; + }); + + it("single page: one HTTP call (page=1, per_page=MAX_PER_PAGE, fields=id,name,private_storage_type), one RECEIVE dispatch with the raw array (SDS:918)", async () => { + const capturedCalls = []; + getRequest.mockImplementation((req, rec, url) => (params) => { + capturedCalls.push({ url, params }); + return () => + Promise.resolve({ + response: { data: [{ id: 1 }, { id: 2 }], last_page: 1 } + }); + }); + + store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); + await flushPromises(); + + expect(capturedCalls).toHaveLength(1); + expect(capturedCalls[0].url).toBe( + "https://summit-api.example.com/api/v1/summits/1/media-upload-types" + ); + expect(capturedCalls[0].params).toMatchObject({ + page: 1, + per_page: 100, + fields: "id,name,private_storage_type" + }); + + const receives = store + .getActions() + .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS"); + expect(receives).toHaveLength(1); + expect(receives[0].payload).toEqual([{ id: 1 }, { id: 2 }]); + }); + + 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 () => { + let page2Resolve; + const page2Promise = new Promise((resolve) => { + page2Resolve = resolve; + }); + + getRequest.mockImplementation(() => (params) => () => { + if (params.page === 1) + return Promise.resolve({ + response: { data: [{ id: 1 }], last_page: 3 } + }); + if (params.page === 2) return page2Promise; + return Promise.resolve({ + response: { data: [{ id: 3 }], last_page: 3 } + }); + }); + + store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); + await flushPromises(); // page 1 and page 3 resolve; page 2 still pending + + expect( + store.getActions().filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS") + ).toHaveLength(0); + + page2Resolve({ response: { data: [{ id: 2 }], last_page: 3 } }); + await flushPromises(); + + expect(getRequest).toHaveBeenCalledTimes(3); + const receives = store + .getActions() + .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS"); + expect(receives).toHaveLength(1); + 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 () => { + store = mockStore({ + currentSummitState: { currentSummit: { id: 0 } } + }); + + store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); + await flushPromises(); + + expect(store.getActions()).toEqual([]); + expect(getRequest).not.toHaveBeenCalled(); + expect(methods.getAccessTokenSafely).not.toHaveBeenCalled(); + }); + + it("summit switched mid-flight (success path, single-page): page resolves AFTER the summit id changed → RECEIVE not dispatched", 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 called but pending + + summitId = 2; // switch summit + + 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 → stopLoading fires + }); + + it("summit switched mid-flight (success path, multi-page): fan-out resolves after the switch → RECEIVE not dispatched with the stale accumulation", 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) + ); + + const fanOutResolvers = []; + getRequest.mockImplementation(() => (params) => () => { + if (params.page === 1) + return Promise.resolve({ + response: { data: [{ id: 1 }], last_page: 3 } + }); + return new Promise((resolve) => { + fanOutResolvers.push(resolve); + }); + }); + + const thunkFn = DropboxSyncActions.getAllMediaUploadTypesForAllowlist(); + thunkFn(mockDispatch, mockGetState); + await flushPromises(); // page 1 resolves, fan-out starts (pages 2+3 pending) + + expect(fanOutResolvers).toHaveLength(2); + summitId = 2; // switch summit before fan-out resolves + + fanOutResolvers.forEach((r) => + r({ response: { data: [{ id: 99 }], last_page: 3 } }) + ); + await flushPromises(); + + const types = dispatched.map((a) => a.type); + expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS"); + expect(types).toContain("STOP_LOADING"); + }); + + it("summit switched mid-flight (REJECTION path): a page REJECTS after the switch → ERROR not dispatched either (the reset slice must not gain a stale error) — the fixture must actually reject or the assertion is vacuous", 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 pageReject; + getRequest.mockImplementation( + () => () => () => + new Promise((_, reject) => { + pageReject = reject; + }) + ); + + const thunkFn = DropboxSyncActions.getAllMediaUploadTypesForAllowlist(); + thunkFn(mockDispatch, mockGetState); + await flushPromises(); // token resolves, page 1 called but pending + + summitId = 2; // switch summit + + pageReject(new Error("network error")); // page rejects AFTER switch + await flushPromises(); + + const types = dispatched.map((a) => a.type); + expect(types).not.toContain("ALLOWLIST_OPTIONS_ERROR"); + expect(types).toContain("STOP_LOADING"); // isNewest=true + }); + + it("A→B→A superseded invocation: hold invocation 1's pages, start invocation 2 for the SAME summit, resolve invocation 1 → its RECEIVE and stopLoading are suppressed by the sequence token even though the summit id matches; only invocation 2's dispatches land", async () => { + let callCount = 0; + let inv1Resolve; + + getRequest.mockImplementation(() => () => () => { + callCount++; + if (callCount === 1) { + return new Promise((resolve) => { + inv1Resolve = resolve; + }); + } + return Promise.resolve({ + response: { data: [{ id: "inv2" }], 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: [{ id: "inv1" }], last_page: 1 } }); + await flushPromises(); + + // Inv 1 dispatched nothing new — both RECEIVE and stopLoading suppressed + expect(store.getActions()).toHaveLength(actionsAfterInv2); + + const receives = store + .getActions() + .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS"); + expect(receives).toHaveLength(1); + 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 + const LAST_PAGE = 12; + const holdResolves = []; + let callCount = 0; + + getRequest.mockImplementation(() => () => () => { + callCount++; + const currentCall = callCount; + if (currentCall === 1) { + return Promise.resolve({ + response: { data: [], last_page: LAST_PAGE } + }); + } + if (currentCall >= 2 && currentCall <= 11) { + return new Promise((resolve) => { + holdResolves.push(resolve); + }); + } + // currentCall === 12: inv 2's page 1 — never resolved in this test + if (currentCall === 12) return new Promise(() => {}); + // Should NOT be reached — inv 1's queued page 12 must be suppressed + return Promise.resolve({ response: { data: [], last_page: 1 } }); + }); + + store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); + await flushPromises(); + // Page 1 resolved, 10 pooled pages started (calls 2..11), page 12 queued + expect(callCount).toBe(11); + + store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); + await flushPromises(); + // Inv 2's page 1 called (call 12) + expect(callCount).toBe(12); + + // Release one pooled page → pLimit dequeues page 12 callback + // But stillCurrent()=false → returns empty placeholder, no getRequest call + holdResolves[0]({ response: { data: [], last_page: LAST_PAGE } }); + await flushPromises(); + + 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")) + ); + + store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain("REQUEST_ALLOWLIST_OPTIONS"); + expect(types).toContain("ALLOWLIST_OPTIONS_ERROR"); + expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS"); + }); + + it("mid-fan-out failure (page 2 rejects, pages 1+3 succeed): ERROR dispatched, RECEIVE never dispatched with any partial list", async () => { + getRequest.mockImplementation(() => (params) => () => { + if (params.page === 1) + return Promise.resolve({ + response: { data: [{ id: 1 }], last_page: 3 } + }); + if (params.page === 2) return Promise.reject(new Error("page 2 error")); + return Promise.resolve({ + response: { data: [{ id: 3 }], last_page: 3 } + }); + }); + + store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist()); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS"); + expect(types).toContain("ALLOWLIST_OPTIONS_ERROR"); + }); + + it("a rejected page never produces a RECEIVE whose payload is undefined or non-array", async () => { + getRequest.mockImplementation( + () => () => () => Promise.reject(new Error("error")) + ); + + 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(); + + const dispatchedTypes = new Set(store.getActions().map((a) => a.type)); + const mediaUploadTypes = new Set( + Object.values(MediaUploadActions).filter((v) => typeof v === "string") + ); + + dispatchedTypes.forEach((type) => { + expect(mediaUploadTypes.has(type)).toBe(false); + }); + }); + + it("PUT-body pin (regression, passes before AND after): updateSyncConfig({materialized_media_upload_types: [...]}) forwards the key verbatim in the PUT body", async () => { + const data = { materialized_media_upload_types: ["type-a", "type-b"] }; + store.dispatch(DropboxSyncActions.updateSyncConfig(data)); + await flushPromises(); + + expect(putRequest).toHaveBeenCalledTimes(1); + expect(putRequest.mock.calls[0][3]).toEqual(data); + }); +}); diff --git a/src/actions/dropbox-sync-actions.js b/src/actions/dropbox-sync-actions.js index a16339563..bba4db31a 100644 --- a/src/actions/dropbox-sync-actions.js +++ b/src/actions/dropbox-sync-actions.js @@ -12,6 +12,7 @@ * */ import T from "i18n-react/dist/i18n-react"; +import pLimit from "p-limit"; import { getRequest, putRequest, @@ -22,16 +23,154 @@ import { showSuccessMessage, authErrorHandler } from "openstack-uicore-foundation/lib/utils/actions"; -import { getAccessTokenSafely } from "../utils/methods"; +import { snackbarErrorHandler } from "./base-actions"; +import { getAccessTokenSafely, range } from "../utils/methods"; +import { MAX_PER_PAGE, TEN, TWO } from "../utils/constants"; export const REQUEST_SYNC_CONFIG = "REQUEST_SYNC_CONFIG"; export const RECEIVE_SYNC_CONFIG = "RECEIVE_SYNC_CONFIG"; export const SYNC_CONFIG_UPDATED = "SYNC_CONFIG_UPDATED"; +export const SYNC_CONFIG_ERROR = "SYNC_CONFIG_ERROR"; export const REBUILD_SYNC_DISPATCHED = "REBUILD_SYNC_DISPATCHED"; export const RESYNC_ROOM_DISPATCHED = "RESYNC_ROOM_DISPATCHED"; +export const REQUEST_ALLOWLIST_OPTIONS = "REQUEST_ALLOWLIST_OPTIONS"; +export const RECEIVE_ALLOWLIST_OPTIONS = "RECEIVE_ALLOWLIST_OPTIONS"; +export const ALLOWLIST_OPTIONS_ERROR = "ALLOWLIST_OPTIONS_ERROR"; + const getBaseUrl = () => window.DROPBOX_MATERIALIZER_API_BASE_URL; +// TWO-TIER sequence guard: +// isNewest() — seq only. Gates loading side effects (our stopLoading and +// the dispatch handed to getRequest so snackbarErrorHandler's +// (→ authErrorHandler) unconditional stopLoading is also +// suppressed). Seq-only +// preserves the newest-clears invariant: when the summit +// switches but NO newer fetch starts, isNewest=true lets the +// stale invocation's stopLoading clear the overlay. +// stillCurrent() — seq + summit id. Gates state commits (REQUEST/RECEIVE/ERROR): +// a stale landing must not repopulate the slice that +// SET_CURRENT_SUMMIT just reset. +// Fan-out pages re-check stillCurrent() inside the pLimit callback: a superseded +// invocation's queued identical-key requests would abort the fresh invocation's +// in-flight pages (getRequest cancels by URL+params, stripping only access_token). +let allowlistOptionsSeq = 0; + +export const getAllMediaUploadTypesForAllowlist = + () => async (dispatch, getState) => { + const summitId = getState().currentSummitState?.currentSummit?.id; + // currentSummit defaults to a truthy {id: 0} entity — guard the id, not + // the object (sibling precedent: getSyncConfig). Promise.resolve() so + // every branch returns a thenable. + if (!summitId) return Promise.resolve(); + + allowlistOptionsSeq += 1; + const mySeq = allowlistOptionsSeq; + const isNewest = () => mySeq === allowlistOptionsSeq; + const stillCurrent = () => + isNewest() && + getState().currentSummitState?.currentSummit?.id === summitId; + // Loading-tier dispatch: passed to getRequest so its internal error handler + // (snackbarErrorHandler → authErrorHandler → stopLoading) fires only while + // this invocation is newest. + const loadingDispatch = (action) => { + if (isNewest()) dispatch(action); + }; + // Commit-tier dispatch: state-bearing actions only. + const commitDispatch = (action) => { + if (stillCurrent()) dispatch(action); + }; + + dispatch(startLoading()); // newest by construction at entry + commitDispatch(createAction(REQUEST_ALLOWLIST_OPTIONS)({})); + + const accessToken = await getAccessTokenSafely(); + // Superseded (or summit switched) while awaiting the token → fire nothing: + // an identical-key request from this stale invocation would abort the + // fresh invocation's page 1. + if (!stillCurrent()) { + if (isNewest()) dispatch(stopLoading()); + return Promise.resolve(); + } + + const endpoint = `${window.API_BASE_URL}/api/v1/summits/${summitId}/media-upload-types`; + const baseParams = { + access_token: accessToken, + per_page: MAX_PER_PAGE, + fields: "id,name,private_storage_type" + }; + const limit = pLimit(TEN); + const EMPTY_PAGE = { response: { data: [] } }; + + return getRequest( + createAction("DUMMY"), + createAction("DUMMY"), + endpoint, + snackbarErrorHandler + )({ ...baseParams, page: 1 })(loadingDispatch) + .then(({ response }) => { + const { last_page: lastPage, data: firstPageData } = response; + if (lastPage <= 1) { + commitDispatch( + createAction(RECEIVE_ALLOWLIST_OPTIONS)(firstPageData) + ); + return firstPageData; + } + // local range() is stop-INCLUSIVE: range(TWO, lastPage, 1) === [2..lastPage] + const pageParams = range(TWO, lastPage, 1).map((page) => ({ + ...baseParams, + page + })); + return Promise.all( + pageParams.map((p) => + limit(() => + // Re-check INSIDE the pool callback: a superseded invocation's + // still-queued jobs must not fire (identical-key abort hazard). + stillCurrent() + ? getRequest( + createAction("DUMMY"), + createAction("DUMMY"), + endpoint, + snackbarErrorHandler + )(p)(loadingDispatch) + : Promise.resolve(EMPTY_PAGE) + ) + ) + ).then((responses) => { + // Promise.all preserves input order → page-order accumulation. + const accumulated = [...firstPageData]; + responses.forEach(({ response: pageResponse }) => { + accumulated.push(...pageResponse.data); + }); + commitDispatch(createAction(RECEIVE_ALLOWLIST_OPTIONS)(accumulated)); + return accumulated; + }); + }) + .catch((err) => { + commitDispatch( + createAction(ALLOWLIST_OPTIONS_ERROR)( + err && err.message ? err.message : "fetch failed" + ) + ); + }) + .finally(() => { + loadingDispatch(stopLoading()); + }); + }; + +// Request-identity guard for the sync-config slice, shared by getSyncConfig +// and updateSyncConfig (both write syncConfig + loading, so they form one +// ownership domain — newest invocation wins). Same two-tier scheme as the +// allowlist aggregator above: isNewest() gates loading side effects (incl. +// the dispatch handed to uicore, whose authErrorHandler dispatches an +// unconditional stopLoading), stillCurrent() additionally requires the summit +// unchanged and gates state commits. Without it: a response captured for +// summit A lands after a switch to B and repopulates B's view with A's +// config; and uicore's identical-key abort (a second GET from another +// Locations page mount cancels the first) makes the stale GET's catch clear +// the loading flag while the fresh GET is still in flight. +let syncConfigSeq = 0; + export const getSyncConfig = () => async (dispatch, getState) => { const { currentSummitState } = getState(); const baseUrl = getBaseUrl(); @@ -39,23 +178,59 @@ export const getSyncConfig = () => async (dispatch, getState) => { if (!baseUrl || !summitId) return; + syncConfigSeq += 1; + const mySeq = syncConfigSeq; + const isNewest = () => mySeq === syncConfigSeq; + const stillCurrent = () => + isNewest() && getState().currentSummitState?.currentSummit?.id === summitId; + const loadingDispatch = (action) => { + if (isNewest()) dispatch(action); + }; + const commitDispatch = (action) => { + if (stillCurrent()) dispatch(action); + }; + + // Pre-token, same as updateSyncConfig: syncLoading goes true the instant + // the GET starts, so the toggle/Save/Rebuild guards disable mutations for + // the GET's whole duration (including a slow token refresh). Otherwise a + // PUT started during that window could interleave with the GET — whichever + // response landed first would clear the shared flag early, and a stale GET + // response could overwrite a newer PUT's config in redux. + dispatch(createAction(REQUEST_SYNC_CONFIG)({})); // newest by construction + const accessToken = await getAccessTokenSafely(); + // Superseded (or summit switched) while awaiting the token: fire nothing. + // The newer invocation — or the SET_CURRENT_SUMMIT slice reset — owns the + // loading flag now. If we are still the NEWEST owner (summit switched, no + // newer sibling), we may have inherited the global overlay from a PUT this + // GET superseded — that PUT's own terminal dispatches are now + // sequence-suppressed, so clearing here is the only live path (mirrors the + // PUT bail below). + if (!stillCurrent()) { + if (isNewest()) dispatch(stopLoading()); + return; + } const params = { access_token: accessToken }; return getRequest( - createAction(REQUEST_SYNC_CONFIG), - createAction(RECEIVE_SYNC_CONFIG), + // null request action: REQUEST_SYNC_CONFIG already fired above (pre-token). + null, + createAction("DUMMY"), `${baseUrl}/api/v1/sync/config/${summitId}/`, - authErrorHandler - )(params)(dispatch) - .then(() => { - dispatch(stopLoading()); + snackbarErrorHandler + )(params)(loadingDispatch) + .then(({ response }) => { + commitDispatch(createAction(RECEIVE_SYNC_CONFIG)({ response })); + loadingDispatch(stopLoading()); }) .catch(() => { - dispatch(createAction(RECEIVE_SYNC_CONFIG)({})); + // Genuine failure of the current request: clear the flag (and reset the + // config) as before. A superseded request's failure — including the + // identical-key abort — is suppressed: the newer request owns the flag. + commitDispatch(createAction(RECEIVE_SYNC_CONFIG)({})); }); }; @@ -66,27 +241,81 @@ export const updateSyncConfig = (data) => async (dispatch, getState) => { if (!baseUrl || !summitId) return; - const accessToken = await getAccessTokenSafely(); + syncConfigSeq += 1; + const mySeq = syncConfigSeq; + const isNewest = () => mySeq === syncConfigSeq; + const stillCurrent = () => + isNewest() && getState().currentSummitState?.currentSummit?.id === summitId; + const loadingDispatch = (action) => { + if (isNewest()) dispatch(action); + }; + const commitDispatch = (action) => { + if (stillCurrent()) dispatch(action); + }; + // Dispatched BEFORE the token await: REQUEST_SYNC_CONFIG flips + // dropboxSyncState.loading for the full thunk duration, so the + // Save/toggle/Rebuild disabled={syncLoading} guards are mutually exclusive + // with an in-flight save (not just with the config GET) — including during + // a slow token refresh, when neither loading flag would otherwise be set + // and a second click could start an overlapping PUT. dispatch(startLoading()); + dispatch(createAction(REQUEST_SYNC_CONFIG)({})); // newest by construction + + const accessToken = await getAccessTokenSafely(); + // Superseded (or summit switched) while awaiting the token: don't PUT. + // We started the global overlay, so clear it if no newer invocation has + // taken ownership; the slice flag belongs to the newer invocation (or was + // reset by SET_CURRENT_SUMMIT). + if (!stillCurrent()) { + if (isNewest()) dispatch(stopLoading()); + return; + } const params = { access_token: accessToken }; return putRequest( + // null request action: REQUEST_SYNC_CONFIG already fired above (pre-token), + // so the PUT itself must not re-dispatch it. null, - createAction(SYNC_CONFIG_UPDATED), + createAction("DUMMY"), `${baseUrl}/api/v1/sync/config/${summitId}/`, data, - authErrorHandler - )(params)(dispatch) - .then(() => { - dispatch(stopLoading()); - dispatch(showSuccessMessage(T.translate("dropbox_sync.config_saved"))); + snackbarErrorHandler + )(params)(loadingDispatch) + .then(({ response }) => { + // Commit only for the summit the save was issued against — a stale + // response must not repopulate another summit's slice. + commitDispatch(createAction(SYNC_CONFIG_UPDATED)({ response })); + loadingDispatch(stopLoading()); + // The save itself succeeded server-side, so the toast is truthful even + // after a summit switch; suppress it only when superseded. + loadingDispatch( + showSuccessMessage(T.translate("dropbox_sync.config_saved")) + ); + // Superseded on the SAME summit: our commit was suppressed, and the + // superseding GET may have been answered with a pre-save snapshot (the + // server can serve the GET before finishing this write) — redux would + // then hold stale config indefinitely, since nothing else refetches. + // Issue a fresh GET to converge on the saved server state. GETs never + // trigger refetches themselves, so this cannot loop. Summit-switched: + // skip — returning to this summit remounts and refetches anyway. + if ( + !isNewest() && + getState().currentSummitState?.currentSummit?.id === summitId + ) { + dispatch(getSyncConfig()); + } }) .catch(() => { - dispatch(stopLoading()); + loadingDispatch(stopLoading()); + // Clear the REQUEST_SYNC_CONFIG loading flag without touching the + // stored syncConfig (unlike getSyncConfig's catch, which resets it). + // Suppressed when superseded/switched: the newer invocation (or the + // SET_CURRENT_SUMMIT reset) owns the flag. + commitDispatch(createAction(SYNC_CONFIG_ERROR)({})); }); }; diff --git a/src/components/locations/allowlist-panel.js b/src/components/locations/allowlist-panel.js new file mode 100644 index 000000000..697784862 --- /dev/null +++ b/src/components/locations/allowlist-panel.js @@ -0,0 +1,324 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useEffect, useMemo, useState } from "react"; +import T from "i18n-react/dist/i18n-react"; +import { + Alert, + Box, + Button, + Checkbox, + Chip, + FormControlLabel, + FormGroup, + Typography +} from "@mui/material"; +import { + DROPBOX_STORAGE, + RECONCILE_CASE, + isValidEntry, + reconcileAllowlist +} from "../../models/materializer-allowlist"; + +// Stable sentinel used as a fallback when redux fields are absent/non-array. +// A module-scope constant has a fixed identity, so memo/effect deps that +// reference it don't change on every render (preventing "Maximum update depth +// exceeded" when RECEIVE_SYNC_CONFIG omits materialized_media_upload_types). +const EMPTY_ARRAY = []; + +// Badge i18n keys resolved ONLY through this static map keyed by RECONCILE_CASE +// (the "constrained key map" i18n rule). OK renders no badge. +const BADGE_KEY_BY_CASE = { + [RECONCILE_CASE.INVALID]: "dropbox_sync.allowlist_badge_invalid", + [RECONCILE_CASE.OK]: null, + [RECONCILE_CASE.RENAMED]: "dropbox_sync.allowlist_badge_renamed", + [RECONCILE_CASE.NOT_DROPBOX]: "dropbox_sync.allowlist_badge_not_dropbox", + [RECONCILE_CASE.RENAMED_NOT_DROPBOX]: + "dropbox_sync.allowlist_badge_renamed_not_dropbox", + [RECONCILE_CASE.RECREATED]: "dropbox_sync.allowlist_badge_recreated", + [RECONCILE_CASE.RECREATED_NOT_DROPBOX]: + "dropbox_sync.allowlist_badge_recreated_not_dropbox", + [RECONCILE_CASE.AMBIGUOUS]: "dropbox_sync.allowlist_badge_ambiguous", + [RECONCILE_CASE.MISSING]: "dropbox_sync.allowlist_badge_missing" +}; + +const BADGE_COLOR_BY_CASE = { + [RECONCILE_CASE.INVALID]: "error", + [RECONCILE_CASE.OK]: "default", + [RECONCILE_CASE.RENAMED]: "warning", + [RECONCILE_CASE.NOT_DROPBOX]: "warning", + [RECONCILE_CASE.RENAMED_NOT_DROPBOX]: "warning", + [RECONCILE_CASE.RECREATED]: "info", + [RECONCILE_CASE.RECREATED_NOT_DROPBOX]: "warning", + [RECONCILE_CASE.AMBIGUOUS]: "error", + [RECONCILE_CASE.MISSING]: "error" +}; + +function AllowlistPanel({ + syncConfig, + allowlistOptions, + syncLoading, + onSave, + onRetryOptions +}) { + const storedTypes = Array.isArray(syncConfig.materialized_media_upload_types) + ? syncConfig.materialized_media_upload_types + : EMPTY_ARRAY; + const options = Array.isArray(allowlistOptions?.options) + ? allowlistOptions.options + : EMPTY_ARRAY; + const optionsError = allowlistOptions?.error ?? null; + // Until the options fetch has committed once, reconciling stored rows would + // badge everything Case 8 ("missing") against a list that merely hasn't + // arrived. The global loading overlay normally hides this, but it is a + // shared non-ref-counted boolean — any parallel fetch (getLocations) that + // finishes first drops it mid-flight. Gate on data, not on the overlay. + const optionsLoaded = allowlistOptions?.loaded === true; + + // Derived state, not duplicate state: rows are a pure function of + // storedTypes + options, so they recompute in-render — no commit ever pairs + // fresh inputs with stale rows. + const storedRows = useMemo( + () => + reconcileAllowlist(storedTypes, options).map((row, index) => ({ + ...row, + uiKey: `stored-${index}` + })), + [storedTypes, options] + ); + + // Selection model — minimal local state, derived rendering (see SDS § State). + const [uncheckedKeys, setUncheckedKeys] = useState(() => new Set()); + const [removedKeys, setRemovedKeys] = useState(() => new Set()); + const [pickedIds, setPickedIds] = useState(() => new Set()); + + // Reset the selection whenever the stored allowlist or the options list + // changes (the rows themselves recompute in-render via the memo above). + useEffect(() => { + setUncheckedKeys(new Set()); + setRemovedKeys(new Set()); + setPickedIds(new Set()); + }, [storedTypes, options]); + + // ---- allowlist selection derivation ------------------------------------- + + const effectiveId = (row) => row.savePayload?.id ?? row.entry?.id; + + const isRowSelected = (row) => + !removedKeys.has(row.uiKey) && + !uncheckedKeys.has(row.uiKey) && + row.savePayload !== null; + + const visibleStoredRows = storedRows.filter( + (row) => !removedKeys.has(row.uiKey) + ); + + const suppressedIds = new Set( + visibleStoredRows + .map(effectiveId) + .filter((id) => id !== undefined && id !== null) + ); + + const availableOptions = options.filter( + (opt) => + opt.private_storage_type === DROPBOX_STORAGE && !suppressedIds.has(opt.id) + ); + + const selectedStoredRows = storedRows.filter(isRowSelected); + const selectedCount = selectedStoredRows.length + pickedIds.size; + // syncLoading matches the Sync toggle / Rebuild guards: no Save while a + // config GET or PUT is in flight (updateSyncConfig dispatches + // REQUEST_SYNC_CONFIG pre-token, so overlapping saves are mutually + // exclusive too). !optionsLoaded: never save rows reconciled against an + // options list that hasn't arrived. + const saveDisabled = + optionsError !== null || + !optionsLoaded || + selectedCount === 0 || + syncLoading; + + const buildSavePayload = () => { + const fromStored = selectedStoredRows.map((row) => row.savePayload); + // Defensive: a picked id normally always resolves in `options` (the + // [storedTypes, options] reset effect clears pickedIds whenever options + // change). Guard the find() anyway so a future re-fetch trigger that clears + // options while picks are staged can't dereference undefined here. + const fromPicked = [...pickedIds] + .map((id) => options.find((o) => o.id === id)) + .filter(Boolean) + .map((opt) => ({ id: opt.id, name: opt.name })); + const seen = new Set(); + const deduped = []; + [...fromStored, ...fromPicked].forEach((item) => { + if (seen.has(item.id)) return; // materializer PUT 400s on duplicate ids + seen.add(item.id); + deduped.push(item); + }); + return deduped; + }; + + const toggleKey = (setState, key) => { + setState((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const handleToggleStored = (uiKey) => toggleKey(setUncheckedKeys, uiKey); + const handleRemoveStored = (uiKey) => + setRemovedKeys((prev) => new Set(prev).add(uiKey)); + const handleTogglePicked = (id) => toggleKey(setPickedIds, id); + const handleSaveAllowlist = () => onSave(buildSavePayload()); + + const bannerText = () => { + if (!syncConfig.dropbox_sync_enabled) { + return T.translate("dropbox_sync.banner_inactive"); + } + const validNames = storedTypes.filter(isValidEntry).map((t) => t.name); + if (validNames.length === 0) { + return T.translate("dropbox_sync.banner_active_empty"); + } + return T.translate("dropbox_sync.banner_active", { + types: validNames.join(", ") + }); + }; + + return ( + + + {T.translate("dropbox_sync.allowlist_label")} + + + {bannerText()} + + + {T.translate("dropbox_sync.allowlist_helper")} + + + {optionsError !== null ? ( + + {T.translate("dropbox_sync.allowlist_retry")} + + } + > + {T.translate("dropbox_sync.allowlist_error")} + + ) : ( + optionsLoaded && ( + + {visibleStoredRows.map((row) => { + const badgeKey = BADGE_KEY_BY_CASE[row.caseId]; + const name = row.entry?.name ?? ""; + // Interactive controls (the Remove button) render as + // siblings of the FormControlLabel, never inside its + // + ) + )} + + + ); +} + +export default AllowlistPanel; diff --git a/src/i18n/en.json b/src/i18n/en.json index 4861975f0..0717fc761 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4251,7 +4251,23 @@ "config_saved": "Sync configuration saved successfully.", "resync_helper": "Use the sync icon to re-sync individual room folders to Dropbox.", "resync_tooltip": "Re-sync Dropbox folder", - "resync_dispatched": "Room re-sync task has been dispatched." + "resync_dispatched": "Room re-sync task has been dispatched.", + "allowlist_label": "Materialized media upload types", + "allowlist_helper": "Only uploads of the selected types are copied to Dropbox. At least one type is required. To stop file sync entirely for this summit, use the Synchronization toggle above.", + "allowlist_save": "Save Allowlist", + "allowlist_error": "Couldn't load media upload types", + "allowlist_retry": "Retry", + "allowlist_badge_invalid": "invalid stored entry — remove required", + "allowlist_badge_renamed": "renamed: was '{stored}', now '{current}'", + "allowlist_badge_not_dropbox": "no longer DropBox — uploads of this type will still be matched by name; consider removing", + "allowlist_badge_renamed_not_dropbox": "renamed AND no longer DropBox — was '{stored}', now '{current}'; consider removing", + "allowlist_badge_recreated": "re-created with new id — was id {storedId}, now id {currentId}", + "allowlist_badge_recreated_not_dropbox": "re-created with new id AND no longer DropBox — was id {storedId}, now id {currentId}; consider removing", + "allowlist_badge_ambiguous": "ambiguous: {count} current types share this name — pick one manually or remove", + "allowlist_badge_missing": "missing — type may have been deleted", + "banner_active": "Materializer Active — Syncing: {types}", + "banner_active_empty": "Materializer Active — no types selected (nothing will sync)", + "banner_inactive": "Materializer Inactive" }, "refund_form": { "reason": "Reason for Refund", diff --git a/src/models/materializer-allowlist.js b/src/models/materializer-allowlist.js new file mode 100644 index 000000000..158b23b26 --- /dev/null +++ b/src/models/materializer-allowlist.js @@ -0,0 +1,161 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import { TWO } from "../utils/constants"; + +export const DROPBOX_STORAGE = "DropBox"; + +/** + * JS approximation of Python str.casefold() — the worker's key-space + * (build_allowlist_set uses strip().casefold()). toUpperCase() first expands + * full case foldings (ß→SS, fi→FI) that toLowerCase() alone would miss. + * KNOWN DIVERGENCE (accepted, see SDS plan note): JS merges dotless ı with i + * while Python casefold keeps them distinct. Pinned by the divergence fixture. + * This is the ONLY normalization site for the allowlist feature. + */ +export const normalizeName = (name) => + typeof name === "string" ? name.trim().toUpperCase().toLowerCase() : ""; + +export const isValidEntry = (entry) => + entry !== null && + typeof entry === "object" && + !Array.isArray(entry) && + typeof entry.id === "number" && + Number.isInteger(entry.id) && + entry.id > 0 && + typeof entry.name === "string" && + entry.name.trim().length > 0; + +export const RECONCILE_CASE = { + INVALID: 0, + OK: 1, + RENAMED: 2, + NOT_DROPBOX: 3, + RENAMED_NOT_DROPBOX: 4, + RECREATED: 5, + RECREATED_NOT_DROPBOX: 6, + AMBIGUOUS: 7, + MISSING: 8 +}; + +const reconcileValidEntry = (entry, types) => { + const storedNorm = normalizeName(entry.name); + const byId = types.find((t) => t.id === entry.id); + + if (byId) { + const isDropbox = byId.private_storage_type === DROPBOX_STORAGE; + const nameAgrees = normalizeName(byId.name) === storedNorm; + if (nameAgrees && isDropbox) { + return { + entry, + caseId: RECONCILE_CASE.OK, + badgeParams: {}, + savePayload: { id: entry.id, name: entry.name }, + selectable: true + }; + } + if (!nameAgrees && isDropbox) { + return { + entry, + caseId: RECONCILE_CASE.RENAMED, + badgeParams: { stored: entry.name, current: byId.name }, + savePayload: { id: entry.id, name: byId.name }, + selectable: true + }; + } + if (nameAgrees && !isDropbox) { + return { + entry, + caseId: RECONCILE_CASE.NOT_DROPBOX, + badgeParams: {}, + savePayload: { id: entry.id, name: entry.name }, + selectable: true + }; + } + return { + entry, + caseId: RECONCILE_CASE.RENAMED_NOT_DROPBOX, + badgeParams: { stored: entry.name, current: byId.name }, + savePayload: { id: entry.id, name: byId.name }, + selectable: true + }; + } + + const nameMatches = types.filter((t) => normalizeName(t.name) === storedNorm); + + if (nameMatches.length === 0) { + return { + entry, + caseId: RECONCILE_CASE.MISSING, + badgeParams: {}, + savePayload: { id: entry.id, name: entry.name }, + selectable: true + }; + } + + if (nameMatches.length >= TWO) { + return { + entry, + caseId: RECONCILE_CASE.AMBIGUOUS, + badgeParams: { count: nameMatches.length }, + savePayload: { id: entry.id, name: entry.name }, + selectable: false + }; + } + + const [match] = nameMatches; + const isDropbox = match.private_storage_type === DROPBOX_STORAGE; + return { + entry, + caseId: isDropbox + ? RECONCILE_CASE.RECREATED + : RECONCILE_CASE.RECREATED_NOT_DROPBOX, + badgeParams: { storedId: entry.id, currentId: match.id }, + savePayload: { id: match.id, name: match.name }, + selectable: true + }; +}; + +/** + * Reconcile the materializer-stored allowlist against the live MediaUploadType + * list (the FULL, unfiltered list — the DropBox filter only gates NEW picks). + * Returns one row per surviving stored entry: + * { entry, caseId, badgeParams, savePayload|null, selectable } + * Pre-table pipeline: invalid entries take Case 0 (null payload, non-selectable) + * and never participate in dedup; valid entries dedup by id, first occurrence + * wins (later valid duplicates produce no row) so valid data always survives. + */ +export const reconcileAllowlist = (stored, allTypes) => { + const list = Array.isArray(stored) ? stored : []; + const types = Array.isArray(allTypes) ? allTypes : []; + const seenValidIds = new Set(); + const rows = []; + + list.forEach((entry) => { + if (!isValidEntry(entry)) { + rows.push({ + entry, + caseId: RECONCILE_CASE.INVALID, + badgeParams: {}, + savePayload: null, + selectable: false + }); + return; + } + if (seenValidIds.has(entry.id)) return; // silent first-occurrence dedup + seenValidIds.add(entry.id); + rows.push(reconcileValidEntry(entry, types)); + }); + + return rows; +}; diff --git a/src/pages/locations/__tests__/location-list-page-allowlist.test.js b/src/pages/locations/__tests__/location-list-page-allowlist.test.js new file mode 100644 index 000000000..ae379b68d --- /dev/null +++ b/src/pages/locations/__tests__/location-list-page-allowlist.test.js @@ -0,0 +1,1091 @@ +import React from "react"; +import { screen, fireEvent, act } from "@testing-library/react"; +import LocationListPage from "../location-list-page"; +import { + normalizeName, + isValidEntry, + RECONCILE_CASE, + reconcileAllowlist +} from "../../../models/materializer-allowlist"; +import { renderWithRedux } from "../../../utils/test-utils"; +import { + getSyncConfig, + updateSyncConfig, + getAllMediaUploadTypesForAllowlist +} from "../../../actions/dropbox-sync-actions"; + +// i18n mock echoes the key, and appends the interpolation params so badge +// substitutions (stored/current names, ids, counts) are assertable. +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (key, params) => + params && Object.keys(params).length + ? `${key} ${JSON.stringify(params)}` + : key +})); + +jest.mock("../../../components/summit-dropdown", () => () => null); + +jest.mock("../../../actions/summit-actions", () => ({ + getSummitById: jest.fn(() => () => Promise.resolve()) +})); + +jest.mock("../../../actions/location-actions", () => ({ + getLocations: jest.fn(() => () => Promise.resolve()), + deleteLocation: jest.fn(() => () => Promise.resolve()), + exportLocations: jest.fn(() => () => Promise.resolve()), + updateLocationOrder: jest.fn(() => () => Promise.resolve()), + copyLocations: jest.fn(() => () => Promise.resolve()) +})); + +jest.mock("../../../actions/dropbox-sync-actions", () => ({ + getSyncConfig: jest.fn(() => () => Promise.resolve()), + updateSyncConfig: jest.fn(() => () => Promise.resolve()), + rebuildSync: jest.fn(() => () => Promise.resolve()), + resyncRoom: jest.fn(() => () => Promise.resolve()), + getAllMediaUploadTypesForAllowlist: jest.fn(() => () => Promise.resolve()) +})); + +// ---- fixtures ------------------------------------------------------------- + +const dropboxType = (id, name) => ({ + id, + name, + private_storage_type: "DropBox" +}); +const localType = (id, name) => ({ id, name, private_storage_type: "Local" }); + +const buildState = ({ syncConfig = {}, allowlistOptions = {} } = {}) => ({ + currentSummitState: { + currentSummit: { id: 1, name: "Test Summit" } + }, + currentLocationListState: { locations: [], totalLocations: 0 }, + dropboxSyncState: { + loading: false, + syncConfig: { + summit_id: 1, + dropbox_sync_enabled: true, + preflight_alert_email: null, + materialized_media_upload_types: [], + ...syncConfig + }, + // loaded defaults true: most tests model the post-fetch state. The + // not-loaded gating test overrides it explicitly. + allowlistOptions: { + options: [], + error: null, + loaded: true, + ...allowlistOptions + } + } +}); + +const mountPanel = (opts) => + renderWithRedux(, { + initialState: buildState(opts) + }); + +const badgeTexts = () => + screen.queryAllByTestId("allowlist-badge").map((n) => n.textContent); + +const saveButton = () => screen.getByTestId("allowlist-save"); + +const clickSave = () => { + act(() => { + fireEvent.click(saveButton()); + }); +}; + +const lastSavePayload = () => { + const call = + updateSyncConfig.mock.calls[updateSyncConfig.mock.calls.length - 1]; + return call[0].materialized_media_upload_types; +}; + +beforeEach(() => { + window.DROPBOX_MATERIALIZER_API_BASE_URL = "https://materializer.test"; + jest.clearAllMocks(); +}); + +afterEach(() => { + delete window.DROPBOX_MATERIALIZER_API_BASE_URL; +}); + +// =========================================================================== +// 3a. Reconciliation named exports (pure unit tests) +// =========================================================================== + +describe("normalizeName", () => { + test("worker parity: trims then casefolds", () => { + expect(normalizeName(" Final Poster ")).toBe("final poster"); + }); + + test("non-ASCII parity: Straße folds equal to STRASSE", () => { + expect(normalizeName("Straße")).toBe(normalizeName("STRASSE")); + expect(normalizeName("Straße")).toBe("strasse"); + }); + + // KNOWN DIVERGENCE from Python str.casefold(): JS toUpperCase().toLowerCase() + // merges the dotless "ı" with "i", while Python casefold keeps them distinct. + // This is an accepted, documented decision (see plan note). If this pin fails + // someone changed the normalization strategy and must re-read that note before + // "fixing" it -- the consequence is a silent Case-1 no-badge + skipped worker + // repair for ı/i name collisions. + test("known-divergence pin: dotless ı folds to i (JS-only, NOT Python parity)", () => { + expect(normalizeName("ı")).toBe("i"); + }); + + test("non-string input yields empty string", () => { + expect(normalizeName(123)).toBe(""); + expect(normalizeName(null)).toBe(""); + expect(normalizeName(undefined)).toBe(""); + }); +}); + +describe("isValidEntry", () => { + test.each([ + ["non-positive (zero) id", { id: 0, name: "X" }], + ["negative id", { id: -5, name: "X" }], + ["bool id", { id: true, name: "X" }], + ["string id", { id: "5", name: "X" }], + ["null id", { id: null, name: "X" }], + ["missing id key", { name: "X" }], + ["empty name", { id: 5, name: "" }], + ["whitespace-only name", { id: 5, name: " " }], + ["numeric name", { id: 5, name: 123 }], + ["null name", { id: 5, name: null }], + ["missing name key", { id: 5 }], + ["non-object entry", "not-an-object"] + ])("rejects %s", (_label, entry) => { + expect(isValidEntry(entry)).toBe(false); + }); + + test("accepts a positive-int id + non-empty string name", () => { + expect(isValidEntry({ id: 5, name: "X" })).toBe(true); + }); +}); + +describe("reconcileAllowlist decision table", () => { + test("row 1: id+name match, DropBox -> OK, no badge, stored payload", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [dropboxType(1, "Final Poster")] + ); + expect(rows).toHaveLength(1); + expect(rows[0].caseId).toBe(RECONCILE_CASE.OK); + expect(rows[0].savePayload).toEqual({ id: 1, name: "Final Poster" }); + expect(rows[0].selectable).toBe(true); + }); + + test("row 2: id match, name differs, DropBox -> RENAMED, current name in payload", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Old Name" }], + [dropboxType(1, "New Name")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.RENAMED); + expect(rows[0].badgeParams).toEqual({ + stored: "Old Name", + current: "New Name" + }); + expect(rows[0].savePayload).toEqual({ id: 1, name: "New Name" }); + }); + + test("row 3: id+name match, storage Local -> NOT_DROPBOX, stored payload", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [localType(1, "Final Poster")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.NOT_DROPBOX); + expect(rows[0].savePayload).toEqual({ id: 1, name: "Final Poster" }); + }); + + test("row 3: id+name match, storage null -> NOT_DROPBOX", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [{ id: 1, name: "Final Poster", private_storage_type: null }] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.NOT_DROPBOX); + }); + + test("row 4: id match, name differs, non-DropBox -> RENAMED_NOT_DROPBOX", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Old Name" }], + [localType(1, "New Name")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.RENAMED_NOT_DROPBOX); + expect(rows[0].badgeParams).toEqual({ + stored: "Old Name", + current: "New Name" + }); + expect(rows[0].savePayload).toEqual({ id: 1, name: "New Name" }); + }); + + test("row 5: id gone, exactly 1 DropBox name-match -> RECREATED auto-relink", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [dropboxType(99, "Final Poster")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.RECREATED); + expect(rows[0].badgeParams).toEqual({ storedId: 1, currentId: 99 }); + expect(rows[0].savePayload).toEqual({ id: 99, name: "Final Poster" }); + }); + + test("row 5: case-insensitive name match relinks (stored 'Final Poster' vs 'FINAL POSTER ')", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [dropboxType(99, "FINAL POSTER ")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.RECREATED); + expect(rows[0].savePayload).toEqual({ id: 99, name: "FINAL POSTER " }); + }); + + test("row 6: id gone, exactly 1 non-DropBox name-match -> RECREATED_NOT_DROPBOX auto-relink", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [localType(99, "Final Poster")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.RECREATED_NOT_DROPBOX); + expect(rows[0].badgeParams).toEqual({ storedId: 1, currentId: 99 }); + expect(rows[0].savePayload).toEqual({ id: 99, name: "Final Poster" }); + }); + + test("row 7: 2 DropBox name-matches -> AMBIGUOUS(2), stored payload, non-selectable", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [dropboxType(99, "Final Poster"), dropboxType(100, "FINAL POSTER")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.AMBIGUOUS); + expect(rows[0].badgeParams).toEqual({ count: 2 }); + expect(rows[0].savePayload).toEqual({ id: 1, name: "Final Poster" }); + expect(rows[0].selectable).toBe(false); + }); + + test("row 7: DropBox + Local name-matches -> AMBIGUOUS(2)", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [dropboxType(99, "Final Poster"), localType(100, "Final Poster")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.AMBIGUOUS); + expect(rows[0].badgeParams).toEqual({ count: 2 }); + }); + + test("row 7: 3 name-matches -> AMBIGUOUS(3) (locks >= 2, not === 2)", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [ + dropboxType(99, "Final Poster"), + localType(100, "Final Poster"), + dropboxType(101, "final poster") + ] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.AMBIGUOUS); + expect(rows[0].badgeParams).toEqual({ count: 3 }); + }); + + test("row 8: id gone, 0 name-matches -> MISSING, stored payload", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [dropboxType(99, "Something Else")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.MISSING); + expect(rows[0].savePayload).toEqual({ id: 1, name: "Final Poster" }); + }); + + test("reconciliation uses the FULL list: stored type now Local -> Case 3, not Case 8", () => { + const rows = reconcileAllowlist( + [{ id: 1, name: "Final Poster" }], + [localType(1, "Final Poster")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.NOT_DROPBOX); + expect(rows[0].caseId).not.toBe(RECONCILE_CASE.MISSING); + }); +}); + +describe("reconcileAllowlist Case 0 + dedup pipeline", () => { + const CASE0 = [ + { id: 0, name: "X" }, + { id: -5, name: "X" }, + { id: true, name: "X" }, + { id: "5", name: "X" }, + { id: null, name: "X" }, + { name: "X" }, + { id: 5, name: "" }, + { id: 5, name: " " }, + { id: 5, name: 123 }, + { id: 5, name: null }, + { id: 5 }, + "not-an-object" + ]; + + test.each(CASE0.map((e, i) => [i, e]))( + "invalid stored entry #%i -> INVALID case, null payload, non-selectable", + (_i, entry) => { + const rows = reconcileAllowlist([entry], [dropboxType(5, "X")]); + expect(rows).toHaveLength(1); + expect(rows[0].caseId).toBe(RECONCILE_CASE.INVALID); + expect(rows[0].savePayload).toBeNull(); + expect(rows[0].selectable).toBe(false); + } + ); + + test("valid dup ids: first occurrence wins, later duplicate produces NO row", () => { + const rows = reconcileAllowlist( + [ + { id: 5, name: "A" }, + { id: 5, name: "B" }, + { id: 7, name: "C" } + ], + [dropboxType(5, "A"), dropboxType(7, "C")] + ); + expect(rows).toHaveLength(2); + expect(rows[0].savePayload).toEqual({ id: 5, name: "A" }); + expect(rows[1].savePayload).toEqual({ id: 7, name: "C" }); + }); + + test("Order A [valid, invalid-same-id]: valid survives, invalid renders Case 0", () => { + const rows = reconcileAllowlist( + [ + { id: 5, name: "X" }, + { id: 5, name: "" } + ], + [dropboxType(5, "X")] + ); + expect(rows).toHaveLength(2); + expect(rows[0].caseId).toBe(RECONCILE_CASE.OK); + expect(rows[1].caseId).toBe(RECONCILE_CASE.INVALID); + }); + + test("Order B [invalid, valid-same-id]: valid ALWAYS survives", () => { + const rows = reconcileAllowlist( + [ + { id: 5, name: "" }, + { id: 5, name: "X" } + ], + [dropboxType(5, "X")] + ); + expect(rows).toHaveLength(2); + expect(rows[0].caseId).toBe(RECONCILE_CASE.INVALID); + expect(rows[1].caseId).toBe(RECONCILE_CASE.OK); + expect(rows[1].savePayload).toEqual({ id: 5, name: "X" }); + }); + + test("all-invalid duplicates -> two independent Case-0 rows", () => { + const rows = reconcileAllowlist( + [ + { id: true, name: "X" }, + { id: true, name: "Y" } + ], + [] + ); + expect(rows).toHaveLength(2); + expect(rows[0].caseId).toBe(RECONCILE_CASE.INVALID); + expect(rows[1].caseId).toBe(RECONCILE_CASE.INVALID); + }); + + test("mutual exclusivity: every row carries exactly one caseId", () => { + const rows = reconcileAllowlist( + [ + { id: 1, name: "Final Poster" }, + { id: 2, name: "Old Deck" }, + { id: 0, name: "bad" } + ], + [dropboxType(1, "Final Poster"), dropboxType(2, "New Deck")] + ); + rows.forEach((r) => { + expect(Object.values(RECONCILE_CASE)).toContain(r.caseId); + }); + expect(rows.map((r) => r.caseId)).toEqual([ + RECONCILE_CASE.OK, + RECONCILE_CASE.RENAMED, + RECONCILE_CASE.INVALID + ]); + }); + + test("Case-0 precedence: {id:0,name:'Final Poster'} with live DropBox match -> INVALID not RECREATED", () => { + const rows = reconcileAllowlist( + [{ id: 0, name: "Final Poster" }], + [dropboxType(99, "Final Poster")] + ); + expect(rows[0].caseId).toBe(RECONCILE_CASE.INVALID); + expect(rows[0].caseId).not.toBe(RECONCILE_CASE.RECREATED); + }); +}); + +// =========================================================================== +// 3c. Panel component tests (render / wiring; classification unit-tested above) +// =========================================================================== + +describe("Allowlist panel - mount + options filtering", () => { + test("dispatches the aggregator on mount inside the materializer gate", async () => { + mountPanel(); + // The aggregator is chained behind getSyncConfig's promise, so it fires + // on the next microtask, not synchronously with mount. + await act(async () => {}); + expect(getAllMediaUploadTypesForAllowlist).toHaveBeenCalledTimes(1); + expect(getSyncConfig).toHaveBeenCalledTimes(1); + }); + + test("mount chains the fetches: the aggregator waits for getSyncConfig to resolve", async () => { + // getSyncConfig has no startLoading of its own but clears the global + // overlay on completion; if the two fetches ran in parallel, whichever + // resolved first would drop the overlay for both, exposing stored rows + // reconciled against an empty options list (every row briefly "missing"). + let resolveSyncConfig; + getSyncConfig.mockImplementationOnce( + () => () => + new Promise((resolve) => { + resolveSyncConfig = resolve; + }) + ); + mountPanel(); + expect(getSyncConfig).toHaveBeenCalledTimes(1); + expect(getAllMediaUploadTypesForAllowlist).not.toHaveBeenCalled(); + await act(async () => { + resolveSyncConfig(); + }); + expect(getAllMediaUploadTypesForAllowlist).toHaveBeenCalledTimes(1); + }); + + test("does NOT dispatch the aggregator when the materializer flag is absent", async () => { + delete window.DROPBOX_MATERIALIZER_API_BASE_URL; + mountPanel(); + await act(async () => {}); + expect(getAllMediaUploadTypesForAllowlist).not.toHaveBeenCalled(); + }); + + test("options are DropBox-filtered; reconciliation uses full list (Case 3 not missing)", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Poster" }] + }, + allowlistOptions: { + options: [localType(1, "Poster"), dropboxType(2, "Deck")] + } + }); + // reconciliation on the full list -> Case 3 badge (not "missing") + expect(badgeTexts()).toEqual([ + expect.stringContaining("allowlist_badge_not_dropbox") + ]); + // only the DropBox type is offered as a new selection option + expect(screen.queryByTestId("allowlist-option-2")).toBeInTheDocument(); + expect(screen.queryByTestId("allowlist-option-1")).not.toBeInTheDocument(); + }); +}); + +describe("Allowlist panel - per-row rendering + save payload", () => { + test("Case 1: no badge; save payload is the stored entry", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Final Poster" }] + }, + allowlistOptions: { options: [dropboxType(1, "Final Poster")] } + }); + expect(badgeTexts()).toHaveLength(0); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "Final Poster" }]); + }); + + test("Case 2: renamed badge shows stored+current; payload uses current name", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Old Name" }] + }, + allowlistOptions: { options: [dropboxType(1, "New Name")] } + }); + const [badge] = badgeTexts(); + expect(badge).toContain("allowlist_badge_renamed"); + expect(badge).toContain("Old Name"); + expect(badge).toContain("New Name"); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "New Name" }]); + }); + + test.each([ + ["Local", localType(1, "Poster")], + ["null", { id: 1, name: "Poster", private_storage_type: null }] + ])( + "Case 3 (%s storage): not-DropBox badge; stored payload", + (_label, type) => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Poster" }] + }, + allowlistOptions: { options: [type] } + }); + expect(badgeTexts()[0]).toContain("allowlist_badge_not_dropbox"); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "Poster" }]); + } + ); + + test("Case 4: renamed + not-DropBox badge; payload uses current name", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Old Name" }] + }, + allowlistOptions: { options: [localType(1, "New Name")] } + }); + const [badge] = badgeTexts(); + expect(badge).toContain("allowlist_badge_renamed_not_dropbox"); + expect(badge).toContain("Old Name"); + expect(badge).toContain("New Name"); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "New Name" }]); + }); + + test("Case 5: re-created badge shows ids; payload relinks to current id+name", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Final Poster" }] + }, + allowlistOptions: { options: [dropboxType(99, "Final Poster")] } + }); + const [badge] = badgeTexts(); + expect(badge).toContain("allowlist_badge_recreated"); + expect(badge).toContain("\"storedId\":1"); + expect(badge).toContain("\"currentId\":99"); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 99, name: "Final Poster" }]); + }); + + test("Case 6: re-created + not-DropBox badge; payload relinks", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Final Poster" }] + }, + allowlistOptions: { options: [localType(99, "Final Poster")] } + }); + expect(badgeTexts()[0]).toContain("allowlist_badge_recreated_not_dropbox"); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 99, name: "Final Poster" }]); + }); + + test("Case 7: ambiguous badge with count; stored payload; Save enabled via other rows", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Final Poster" }] + }, + allowlistOptions: { + options: [ + dropboxType(99, "Final Poster"), + dropboxType(100, "FINAL POSTER") + ] + } + }); + const [badge] = badgeTexts(); + expect(badge).toContain("allowlist_badge_ambiguous"); + expect(badge).toContain("\"count\":2"); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "Final Poster" }]); + }); + + test("Case 8: missing badge; stored payload", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Final Poster" }] + }, + allowlistOptions: { options: [dropboxType(99, "Other")] } + }); + expect(badgeTexts()[0]).toContain("allowlist_badge_missing"); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "Final Poster" }]); + }); +}); + +describe("Allowlist panel - Case 0 mounted sub-fixtures", () => { + const CASE0 = [ + ["zero id", { id: 0, name: "X" }], + ["negative id", { id: -5, name: "X" }], + ["bool id", { id: true, name: "X" }], + ["string id", { id: "5", name: "X" }], + ["null id", { id: null, name: "X" }], + ["missing id", { name: "X" }], + ["empty name", { id: 5, name: "" }], + ["whitespace name", { id: 5, name: " " }], + ["numeric name", { id: 5, name: 123 }], + ["null name", { id: 5, name: null }], + ["missing name", { id: 5 }], + ["non-object", "not-an-object"] + ]; + + test.each(CASE0)( + "%s: invalid badge renders + Save disabled (empty effective selection)", + (_label, entry) => { + mountPanel({ + syncConfig: { materialized_media_upload_types: [entry] }, + allowlistOptions: { options: [] } + }); + expect(badgeTexts()[0]).toContain("allowlist_badge_invalid"); + expect(saveButton()).toBeDisabled(); + } + ); + + test("granular omission: one valid + one Case-0 -> payload has only valid; Save enabled", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [ + { id: 1, name: "Final Poster" }, + { id: 0, name: "bad" } + ] + }, + allowlistOptions: { options: [dropboxType(1, "Final Poster")] } + }); + expect(saveButton()).not.toBeDisabled(); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "Final Poster" }]); + }); +}); + +describe("Allowlist panel - pre-table pipeline (mounted)", () => { + test("pure valid dup: two rows, no badge on survivor, payload has both deduped", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [ + { id: 5, name: "A" }, + { id: 5, name: "B" }, + { id: 7, name: "C" } + ] + }, + allowlistOptions: { options: [dropboxType(5, "A"), dropboxType(7, "C")] } + }); + expect(badgeTexts()).toHaveLength(0); + clickSave(); + expect(lastSavePayload()).toEqual([ + { id: 5, name: "A" }, + { id: 7, name: "C" } + ]); + }); + + test("Order A [valid, invalid]: invalid badge on the invalid; payload only valid", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [ + { id: 5, name: "X" }, + { id: 5, name: "" } + ] + }, + allowlistOptions: { options: [dropboxType(5, "X")] } + }); + expect(badgeTexts()).toEqual([ + expect.stringContaining("allowlist_badge_invalid") + ]); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 5, name: "X" }]); + }); + + test("Order B [invalid, valid]: valid not lost; payload only valid", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [ + { id: 5, name: "" }, + { id: 5, name: "X" } + ] + }, + allowlistOptions: { options: [dropboxType(5, "X")] } + }); + expect(badgeTexts()).toEqual([ + expect.stringContaining("allowlist_badge_invalid") + ]); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 5, name: "X" }]); + }); + + test("all-invalid dup: two invalid badges; Save disabled", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [ + { id: true, name: "X" }, + { id: true, name: "Y" } + ] + }, + allowlistOptions: { options: [] } + }); + expect(badgeTexts()).toHaveLength(2); + badgeTexts().forEach((t) => expect(t).toContain("allowlist_badge_invalid")); + expect(saveButton()).toBeDisabled(); + }); + + test("mounted Case-0 precedence: invalid badge wins over re-created", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 0, name: "Final Poster" }] + }, + allowlistOptions: { options: [dropboxType(99, "Final Poster")] } + }); + expect(badgeTexts()[0]).toContain("allowlist_badge_invalid"); + expect(badgeTexts()[0]).not.toContain("allowlist_badge_recreated"); + }); +}); + +describe("Allowlist panel - payload dedupe under double relink", () => { + test("two stale ids both relinking to one live type -> id appears once in payload", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [ + { id: 1, name: "Final Poster" }, + { id: 2, name: "final poster" } + ] + }, + allowlistOptions: { options: [dropboxType(99, "Final Poster")] } + }); + // both rows render a re-created badge + expect(badgeTexts()).toHaveLength(2); + badgeTexts().forEach((t) => + expect(t).toContain("allowlist_badge_recreated") + ); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 99, name: "Final Poster" }]); + }); +}); + +describe("Allowlist panel - remove vs uncheck (two-set model)", () => { + test("uncheck: row stays, option does NOT resurface, selection empties (Save disabled)", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Poster" }] + }, + allowlistOptions: { options: [dropboxType(1, "Poster")] } + }); + // suppressed while checked + expect(screen.queryByTestId("allowlist-option-1")).not.toBeInTheDocument(); + act(() => { + fireEvent.click(screen.getByTestId("allowlist-check-stored-0")); + }); + // still visible, still suppressing its option, selection now empty + expect(screen.getByTestId("allowlist-check-stored-0")).toBeInTheDocument(); + expect(screen.queryByTestId("allowlist-option-1")).not.toBeInTheDocument(); + expect(saveButton()).toBeDisabled(); + // re-check restores + act(() => { + fireEvent.click(screen.getByTestId("allowlist-check-stored-0")); + }); + expect(saveButton()).not.toBeDisabled(); + }); + + test("remove: row disappears, option resurfaces, re-pick puts live {id,name} back", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Poster" }] + }, + allowlistOptions: { options: [dropboxType(1, "Poster")] } + }); + act(() => { + fireEvent.click(screen.getByTestId("allowlist-remove-stored-0")); + }); + // row gone, option resurfaced + expect( + screen.queryByTestId("allowlist-check-stored-0") + ).not.toBeInTheDocument(); + expect(screen.getByTestId("allowlist-option-1")).toBeInTheDocument(); + // pick the resurfaced option + act(() => { + fireEvent.click(screen.getByTestId("allowlist-option-1")); + }); + expect(saveButton()).not.toBeDisabled(); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "Poster" }]); + }); +}); + +describe("Allowlist panel - Case 7 remove-and-repick", () => { + test("ambiguous row removable; payload drops it; re-pick adds live record", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [ + { id: 1, name: "Final Poster" }, + { id: 2, name: "Keynote Deck" } + ] + }, + allowlistOptions: { + options: [ + dropboxType(99, "Final Poster"), + dropboxType(100, "FINAL POSTER"), + dropboxType(2, "Keynote Deck") + ] + } + }); + // ambiguous badge present, stored entry present in payload while visible + expect(badgeTexts()[0]).toContain("allowlist_badge_ambiguous"); + clickSave(); + expect(lastSavePayload()).toEqual([ + { id: 1, name: "Final Poster" }, + { id: 2, name: "Keynote Deck" } + ]); + // remove the ambiguous row + act(() => { + fireEvent.click(screen.getByTestId("allowlist-remove-stored-0")); + }); + expect(saveButton()).not.toBeDisabled(); + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 2, name: "Keynote Deck" }]); + // re-pick one of the ambiguous live types + act(() => { + fireEvent.click(screen.getByTestId("allowlist-option-99")); + }); + clickSave(); + expect(lastSavePayload()).toEqual([ + { id: 2, name: "Keynote Deck" }, + { id: 99, name: "Final Poster" } + ]); + }); +}); + +describe("Allowlist panel - error + empty gating", () => { + test("error state: options hidden, Alert + Retry rendered, Save disabled", async () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Poster" }] + }, + allowlistOptions: { options: [], error: "fetch failed" } + }); + // Flush the chained mount fetch so it registers as the first call. + await act(async () => {}); + expect(screen.getByTestId("allowlist-error")).toBeInTheDocument(); + expect(screen.queryByTestId("allowlist-option-1")).not.toBeInTheDocument(); + expect(saveButton()).toBeDisabled(); + // Retry re-dispatches the aggregator (already called once on mount) + act(() => { + fireEvent.click(screen.getByTestId("allowlist-retry")); + }); + expect(getAllMediaUploadTypesForAllowlist).toHaveBeenCalledTimes(2); + }); + + test("options not yet loaded: stored rows are NOT reconciled (no Case-8 flash) and Save is disabled", () => { + // The global overlay is a shared non-ref-counted boolean, so a parallel + // fetch (getLocations) finishing first can drop it while the options + // aggregator is still running. The panel must gate on data, not overlay: + // stored rows must not render badged "missing" against an options list + // that merely hasn't arrived, and Save must not persist that state. + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Poster" }] + }, + allowlistOptions: { options: [], error: null, loaded: false } + }); + expect(screen.queryByTestId("allowlist-badge")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("allowlist-check-stored-0") + ).not.toBeInTheDocument(); + expect(saveButton()).toBeDisabled(); + }); + + test("Save AND the sync toggle are disabled while a sync config request is in flight (syncLoading)", () => { + // Same fixture as the recovery test below (which asserts enabled with + // loading false) — only dropboxSyncState.loading differs. Both actions + // (getSyncConfig and updateSyncConfig) flip the flag pre-token, so this + // single guard is what makes GET and PUT mutually exclusive: neither the + // toggle nor Save can start a PUT while the other request is in flight. + const state = buildState({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Poster" }] + }, + allowlistOptions: { options: [dropboxType(1, "Poster")], error: null } + }); + state.dropboxSyncState.loading = true; + renderWithRedux(, { + initialState: state + }); + expect(saveButton()).toBeDisabled(); + // react-switch renders a hidden checkbox input carrying the id + disabled. + expect(document.getElementById("dropbox_sync_enabled")).toBeDisabled(); + }); + + test("recovery: fresh mount with options + selection re-enables Save", () => { + mountPanel({ + syncConfig: { + materialized_media_upload_types: [{ id: 1, name: "Poster" }] + }, + allowlistOptions: { options: [dropboxType(1, "Poster")], error: null } + }); + expect(saveButton()).not.toBeDisabled(); + }); + + test("empty selection disables Save; picking one enables; deselecting re-disables", () => { + mountPanel({ + syncConfig: { materialized_media_upload_types: [] }, + allowlistOptions: { options: [dropboxType(1, "Poster")] } + }); + expect(saveButton()).toBeDisabled(); + act(() => { + fireEvent.click(screen.getByTestId("allowlist-option-1")); + }); + expect(saveButton()).not.toBeDisabled(); + act(() => { + fireEvent.click(screen.getByTestId("allowlist-option-1")); + }); + expect(saveButton()).toBeDisabled(); + }); +}); + +// =========================================================================== +// Stable empty-array fallbacks — regression guard +// =========================================================================== +// Before the fix: `syncConfig.materialized_media_upload_types || []` and +// `allowlistOptions?.options || []` mint a NEW array identity on every render +// when the field is absent/undefined. Both arrays are deps of the reset +// useEffect, so the effect fires every render → 4× setState → re-render loop +// → "Maximum update depth exceeded". +// After the fix: module-scope EMPTY_ARRAY + Array.isArray guard gives a stable +// identity, breaking the loop. +describe("Allowlist panel - stable empty-array fallbacks (regression)", () => { + test("renders without throwing when syncConfig lacks materialized_media_upload_types", () => { + // Simulate an out-of-contract RECEIVE_SYNC_CONFIG that omits the key entirely + const state = { + currentSummitState: { + currentSummit: { id: 1, name: "Test Summit" } + }, + currentLocationListState: { locations: [], totalLocations: 0 }, + dropboxSyncState: { + loading: false, + syncConfig: { + summit_id: 1, + dropbox_sync_enabled: true, + preflight_alert_email: null + // materialized_media_upload_types intentionally omitted + }, + allowlistOptions: { options: [], error: null } + } + }; + renderWithRedux(, { + initialState: state + }); + // Page must render — banner is the first always-visible element in the panel + expect(screen.getByTestId("allowlist-banner")).toBeInTheDocument(); + }); + + test("renders without throwing when allowlistOptions.options is undefined", () => { + const state = { + currentSummitState: { + currentSummit: { id: 1, name: "Test Summit" } + }, + currentLocationListState: { locations: [], totalLocations: 0 }, + dropboxSyncState: { + loading: false, + syncConfig: { + summit_id: 1, + dropbox_sync_enabled: true, + preflight_alert_email: null, + materialized_media_upload_types: [] + }, + allowlistOptions: { options: undefined, error: null } + } + }; + renderWithRedux(, { + initialState: state + }); + expect(screen.getByTestId("allowlist-banner")).toBeInTheDocument(); + }); +}); + +describe("Allowlist panel - saved-state banner", () => { + test("active + non-empty: names in stored order", () => { + mountPanel({ + syncConfig: { + dropbox_sync_enabled: true, + materialized_media_upload_types: [ + { id: 1, name: "Alpha" }, + { id: 2, name: "Bravo" } + ] + }, + allowlistOptions: { + options: [dropboxType(1, "Alpha"), dropboxType(2, "Bravo")] + } + }); + const banner = screen.getByTestId("allowlist-banner").textContent; + expect(banner).toContain("banner_active"); + expect(banner).toContain("Alpha, Bravo"); + }); + + test("active + stored-empty: nothing-will-sync banner", () => { + mountPanel({ + syncConfig: { + dropbox_sync_enabled: true, + materialized_media_upload_types: [] + } + }); + expect(screen.getByTestId("allowlist-banner").textContent).toContain( + "banner_active_empty" + ); + }); + + test("active + only-invalid stored entries: nothing-will-sync banner, no crash", () => { + mountPanel({ + syncConfig: { + dropbox_sync_enabled: true, + materialized_media_upload_types: [null, "oops", { id: 1 }] + } + }); + expect(screen.getByTestId("allowlist-banner").textContent).toContain( + "banner_active_empty" + ); + }); + + test("active + mixed valid/invalid stored entries: only valid names shown", () => { + mountPanel({ + syncConfig: { + dropbox_sync_enabled: true, + materialized_media_upload_types: [ + null, + { id: 1, name: "Alpha" }, + { id: 2 } + ] + }, + allowlistOptions: { options: [dropboxType(1, "Alpha")] } + }); + const banner = screen.getByTestId("allowlist-banner").textContent; + expect(banner).toContain("Alpha"); + expect(banner).not.toContain("undefined"); + }); + + test("inactive: inactive banner regardless of stored allowlist", () => { + mountPanel({ + syncConfig: { + dropbox_sync_enabled: false, + materialized_media_upload_types: [{ id: 1, name: "Alpha" }] + }, + allowlistOptions: { options: [dropboxType(1, "Alpha")] } + }); + expect(screen.getByTestId("allowlist-banner").textContent).toContain( + "banner_inactive" + ); + }); + + test("transition proof: unsaved edit does not move banner; Save PUTs new payload; fresh mount shows new names", () => { + // (a) pre-save mount + mountPanel({ + syncConfig: { + dropbox_sync_enabled: true, + materialized_media_upload_types: [ + { id: 1, name: "Alpha" }, + { id: 2, name: "Bravo" } + ] + }, + allowlistOptions: { + options: [dropboxType(1, "Alpha"), dropboxType(2, "Bravo")] + } + }); + // unsaved picker edit: uncheck Bravo + act(() => { + fireEvent.click(screen.getByTestId("allowlist-check-stored-1")); + }); + // banner reads SAVED config -> unchanged + expect(screen.getByTestId("allowlist-banner").textContent).toContain( + "Alpha, Bravo" + ); + // (b) Save dispatches new payload + clickSave(); + expect(lastSavePayload()).toEqual([{ id: 1, name: "Alpha" }]); + + // (c) fresh mount from the post-save syncConfig -> banner shows only Alpha + mountPanel({ + syncConfig: { + dropbox_sync_enabled: true, + materialized_media_upload_types: [{ id: 1, name: "Alpha" }] + }, + allowlistOptions: { options: [dropboxType(1, "Alpha")] } + }); + const banners = screen.getAllByTestId("allowlist-banner"); + const fresh = banners[banners.length - 1].textContent; + expect(fresh).toContain("Alpha"); + expect(fresh).not.toContain("Bravo"); + }); +}); diff --git a/src/pages/locations/location-list-page.js b/src/pages/locations/location-list-page.js index b80d35451..8bc797101 100644 --- a/src/pages/locations/location-list-page.js +++ b/src/pages/locations/location-list-page.js @@ -18,6 +18,7 @@ import Swal from "sweetalert2"; import Switch from "react-switch"; import SortableTable from "openstack-uicore-foundation/lib/components/table-sortable"; import SummitDropdown from "../../components/summit-dropdown"; +import AllowlistPanel from "../../components/locations/allowlist-panel"; import { getSummitById } from "../../actions/summit-actions"; import { @@ -30,7 +31,8 @@ import { import { getSyncConfig, updateSyncConfig, - rebuildSync + rebuildSync, + getAllMediaUploadTypesForAllowlist } from "../../actions/dropbox-sync-actions"; function LocationListPage({ @@ -41,11 +43,24 @@ function LocationListPage({ dropboxSyncState, ...props }) { + const { + syncConfig, + loading: syncLoading, + allowlistOptions + } = dropboxSyncState; + useEffect(() => { if (currentSummit) { props.getLocations(); if (window.DROPBOX_MATERIALIZER_API_BASE_URL) { - props.getSyncConfig(); + // Chained, not parallel: getSyncConfig has no startLoading of its own + // but clears the global overlay on completion, while the options fetch + // brackets the same (non-ref-counted) overlay — run in parallel, + // whichever resolves first drops the overlay for both, exposing the + // panel with stored rows reconciled against an empty options list. + props + .getSyncConfig() + .then(() => props.getAllMediaUploadTypesForAllowlist()); } } }, [currentSummit?.id]); @@ -102,6 +117,11 @@ function LocationListPage({ }); }; + const handleSaveAllowlist = (types) => + props.updateSyncConfig({ + materialized_media_upload_types: types + }); + const columns = [ { columnKey: "name", value: T.translate("location_list.name") }, { columnKey: "class_name", value: T.translate("location_list.class_name") } @@ -116,7 +136,6 @@ function LocationListPage({ const sortedLocations = locations.sort((a, b) => a.order - b.order); - const { syncConfig, loading: syncLoading } = dropboxSyncState; const showSyncPanel = !!window.DROPBOX_MATERIALIZER_API_BASE_URL; return ( @@ -156,6 +175,14 @@ function LocationListPage({
+ +
{T.translate("dropbox_sync.rebuild_title")}
@@ -229,5 +256,6 @@ export default connect(mapStateToProps, { copyLocations, getSyncConfig, updateSyncConfig, - rebuildSync + rebuildSync, + getAllMediaUploadTypesForAllowlist })(LocationListPage); diff --git a/src/reducers/__tests__/dropbox-sync-reducer.test.js b/src/reducers/__tests__/dropbox-sync-reducer.test.js index cbffa305c..39f4d594c 100644 --- a/src/reducers/__tests__/dropbox-sync-reducer.test.js +++ b/src/reducers/__tests__/dropbox-sync-reducer.test.js @@ -4,15 +4,21 @@ import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { REQUEST_SYNC_CONFIG, RECEIVE_SYNC_CONFIG, - SYNC_CONFIG_UPDATED + SYNC_CONFIG_UPDATED, + SYNC_CONFIG_ERROR, + REQUEST_ALLOWLIST_OPTIONS, + RECEIVE_ALLOWLIST_OPTIONS, + ALLOWLIST_OPTIONS_ERROR } from "../../actions/dropbox-sync-actions"; const DEFAULT_STATE = { syncConfig: { summit_id: null, dropbox_sync_enabled: false, - preflight_alert_email: null + preflight_alert_email: null, + materialized_media_upload_types: [] }, + allowlistOptions: { options: [], error: null, loaded: false }, loading: false }; @@ -67,6 +73,28 @@ describe("dropboxSyncReducer", () => { expect(state.loading).toBe(false); }); + test("SYNC_CONFIG_ERROR clears loading but keeps the stored syncConfig", () => { + const storedConfig = { + summit_id: 1, + dropbox_sync_enabled: true, + preflight_alert_email: null, + materialized_media_upload_types: [{ id: 1, name: "Final Poster" }] + }; + const prevState = { + ...DEFAULT_STATE, + syncConfig: storedConfig, + loading: true + }; + + const state = dropboxSyncReducer(prevState, { + type: SYNC_CONFIG_ERROR, + payload: {} + }); + + expect(state.loading).toBe(false); + expect(state.syncConfig).toEqual(storedConfig); + }); + test("SET_CURRENT_SUMMIT resets to default state", () => { const prevState = { syncConfig: { @@ -102,4 +130,114 @@ describe("dropboxSyncReducer", () => { expect(state).toEqual(DEFAULT_STATE); }); + + // regression pins — must pass before AND after implementation + test("RECEIVE_SYNC_CONFIG populates materialized_media_upload_types when present in response", () => { + const payload = { + response: { + summit_id: 1, + dropbox_sync_enabled: true, + preflight_alert_email: "test@example.com", + materialized_media_upload_types: [ + { id: 1, name: "Video", private_storage_type: "dropbox" } + ] + } + }; + + const state = dropboxSyncReducer(DEFAULT_STATE, { + type: RECEIVE_SYNC_CONFIG, + payload + }); + + expect(state.syncConfig.materialized_media_upload_types).toEqual([ + { id: 1, name: "Video", private_storage_type: "dropbox" } + ]); + }); + + test("SYNC_CONFIG_UPDATED populates materialized_media_upload_types when present in response", () => { + const payload = { + response: { + summit_id: 1, + dropbox_sync_enabled: false, + preflight_alert_email: "test@example.com", + materialized_media_upload_types: [ + { id: 2, name: "Image", private_storage_type: "local" } + ] + } + }; + + const state = dropboxSyncReducer(DEFAULT_STATE, { + type: SYNC_CONFIG_UPDATED, + payload + }); + + expect(state.syncConfig.materialized_media_upload_types).toEqual([ + { id: 2, name: "Image", private_storage_type: "local" } + ]); + }); + + // allowlist action tests + test("REQUEST_ALLOWLIST_OPTIONS resets allowlistOptions and leaves syncConfig untouched", () => { + const prevState = { + ...DEFAULT_STATE, + syncConfig: { ...DEFAULT_STATE.syncConfig, dropbox_sync_enabled: true }, + allowlistOptions: { + options: [{ id: 1, name: "Video", private_storage_type: "dropbox" }], + error: null, + loaded: true + } + }; + + const state = dropboxSyncReducer(prevState, { + type: REQUEST_ALLOWLIST_OPTIONS, + payload: {} + }); + + expect(state.allowlistOptions).toEqual({ + options: [], + error: null, + loaded: false + }); + expect(state.syncConfig.dropbox_sync_enabled).toBe(true); + }); + + test("RECEIVE_ALLOWLIST_OPTIONS sets options array and clears error", () => { + const options = [ + { id: 1, name: "Video", private_storage_type: "dropbox" }, + { id: 2, name: "Image", private_storage_type: "local" } + ]; + + const state = dropboxSyncReducer(DEFAULT_STATE, { + type: RECEIVE_ALLOWLIST_OPTIONS, + payload: options + }); + + expect(state.allowlistOptions).toEqual({ + options, + error: null, + loaded: true + }); + }); + + test("ALLOWLIST_OPTIONS_ERROR clears options and sets error message", () => { + const prevState = { + ...DEFAULT_STATE, + allowlistOptions: { + options: [{ id: 1, name: "Video", private_storage_type: "dropbox" }], + error: null, + loaded: true + } + }; + + const state = dropboxSyncReducer(prevState, { + type: ALLOWLIST_OPTIONS_ERROR, + payload: "fetch failed" + }); + + expect(state.allowlistOptions).toEqual({ + options: [], + error: "fetch failed", + loaded: false + }); + }); }); diff --git a/src/reducers/locations/dropbox-sync-reducer.js b/src/reducers/locations/dropbox-sync-reducer.js index ca670af43..c76b1e7e2 100644 --- a/src/reducers/locations/dropbox-sync-reducer.js +++ b/src/reducers/locations/dropbox-sync-reducer.js @@ -16,15 +16,25 @@ import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { REQUEST_SYNC_CONFIG, RECEIVE_SYNC_CONFIG, - SYNC_CONFIG_UPDATED + SYNC_CONFIG_UPDATED, + SYNC_CONFIG_ERROR, + REQUEST_ALLOWLIST_OPTIONS, + RECEIVE_ALLOWLIST_OPTIONS, + ALLOWLIST_OPTIONS_ERROR } from "../../actions/dropbox-sync-actions"; const DEFAULT_STATE = { syncConfig: { summit_id: null, dropbox_sync_enabled: false, - preflight_alert_email: null + preflight_alert_email: null, + materialized_media_upload_types: [] }, + // loaded stays false until the first RECEIVE for the current summit: the + // panel must not reconcile stored rows against an options list that simply + // hasn't arrived yet (options: [] is also the legitimate loaded-empty + // state, so the flag — not the array — is the "has data" signal). + allowlistOptions: { options: [], error: null, loaded: false }, loading: false }; @@ -46,6 +56,24 @@ const dropboxSyncReducer = (state = DEFAULT_STATE, action) => { syncConfig: payload.response ?? DEFAULT_STATE.syncConfig, loading: false }; + case SYNC_CONFIG_ERROR: + // Failed PUT: clear the in-flight flag but keep the stored syncConfig. + return { ...state, loading: false }; + case REQUEST_ALLOWLIST_OPTIONS: + return { + ...state, + allowlistOptions: { options: [], error: null, loaded: false } + }; + case RECEIVE_ALLOWLIST_OPTIONS: + return { + ...state, + allowlistOptions: { options: payload, error: null, loaded: true } + }; + case ALLOWLIST_OPTIONS_ERROR: + return { + ...state, + allowlistOptions: { options: [], error: payload, loaded: false } + }; case SET_CURRENT_SUMMIT: case LOGOUT_USER: return { ...DEFAULT_STATE };