diff --git a/.github/workflows/firebase-deploy-test.yml b/.github/workflows/firebase-deploy-test.yml index 22f513a..bf84a6a 100644 --- a/.github/workflows/firebase-deploy-test.yml +++ b/.github/workflows/firebase-deploy-test.yml @@ -20,6 +20,11 @@ env: NEXT_PUBLIC_GENERATE_STATE: "https://datapipe-test.web.app/api/generateoauthstate" NEXT_PUBLIC_BASE_URL: "https://datapipe-test.web.app" NEXT_PUBLIC_OSF_ENV: "" + # Google Picker (folder selection for gdrive experiments). The API key is + # restricted to the Picker API + our domains, so it is safe in the browser + # bundle -- not a secret. The project number is public. + NEXT_PUBLIC_GOOGLE_PICKER_API_KEY: "AIzaSyDLg6uprrY5BPjY4ClVZGSvy7sd_ug0t9M" + NEXT_PUBLIC_GDRIVE_PROJECT_NUMBER: "699904257039" jobs: deploy: diff --git a/__tests__/experiment-creation.test.js b/__tests__/experiment-creation.test.js new file mode 100644 index 0000000..d865a31 --- /dev/null +++ b/__tests__/experiment-creation.test.js @@ -0,0 +1,130 @@ +// nanoid v5 ships ESM-only and isn't transformed by the default Jest config +// (node_modules is excluded); experiment-creation.js imports it at the top +// level for the (untested-here) OSF path, so it must be mocked even though +// createProviderExperiment itself never calls it. +jest.mock("nanoid", () => ({ + customAlphabet: () => () => "mocked-id", +})); + +jest.mock("../lib/firebase", () => ({ + auth: { currentUser: null }, + db: {}, +})); + +// firebase/firestore's doc/writeBatch/arrayUnion (used by the untested-here +// OSF path in this module) must not touch a real Firestore instance. +jest.mock("firebase/firestore", () => ({ + doc: jest.fn(() => ({})), + writeBatch: jest.fn(() => ({ + set: jest.fn(), + update: jest.fn(), + commit: jest.fn(() => Promise.resolve()), + })), + arrayUnion: jest.fn((v) => v), +})); + +import { createProviderExperiment } from "../lib/experiment-creation"; +import { auth } from "../lib/firebase"; + +function mockUser({ uid = "user-123", idToken = "id-token-abc" } = {}) { + return { + uid, + getIdToken: jest.fn().mockResolvedValue(idToken), + }; +} + +describe("createProviderExperiment", () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + auth.currentUser = null; + }); + + it("omits parentFolderId from the request body when not provided", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-1" }), + }); + + const result = await createProviderExperiment("gdrive", "My Experiment"); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe("/api/createexperiment"); + const body = JSON.parse(options.body); + expect(body).toEqual({ + provider: "gdrive", + title: "My Experiment", + uid: "user-123", + idToken: "id-token-abc", + }); + expect(body.parentFolderId).toBeUndefined(); + expect(result).toEqual({ experimentId: "exp-1" }); + }); + + it("forwards parentFolderId in the request body when provided", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-2" }), + }); + + const result = await createProviderExperiment( + "gdrive", + "My Experiment", + "folder-xyz" + ); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.parentFolderId).toBe("folder-xyz"); + expect(body).toEqual({ + provider: "gdrive", + title: "My Experiment", + uid: "user-123", + idToken: "id-token-abc", + parentFolderId: "folder-xyz", + }); + expect(result).toEqual({ experimentId: "exp-2" }); + }); + + it("omits parentFolderId when it is falsy (empty string)", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ experimentID: "exp-3" }), + }); + + await createProviderExperiment("gdrive", "My Experiment", ""); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.parentFolderId).toBeUndefined(); + }); + + it("throws when the user is not authenticated", async () => { + auth.currentUser = null; + + await expect( + createProviderExperiment("gdrive", "My Experiment") + ).rejects.toThrow("User not authenticated"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("throws the server error message when the request fails", async () => { + auth.currentUser = mockUser(); + global.fetch.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: "Forbidden" }), + }); + + await expect( + createProviderExperiment("gdrive", "My Experiment") + ).rejects.toThrow("Forbidden"); + }); +}); diff --git a/__tests__/google-picker.test.js b/__tests__/google-picker.test.js new file mode 100644 index 0000000..f96ad0c --- /dev/null +++ b/__tests__/google-picker.test.js @@ -0,0 +1,91 @@ +import { pickDriveFolder } from "../lib/google-picker"; + +// Flushes the microtask queue (all chained Promise .then callbacks that were +// already scheduled) without needing to know exactly how many hops deep the +// module's internal promise chain is. +function flushMicrotasks() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("pickDriveFolder", () => { + beforeEach(() => { + document.body.innerHTML = ""; + delete window.gapi; + delete window.google; + }); + + it("injects the Google API script only once across multiple calls, and resolves/cancels via the picker callback", async () => { + const appendChildSpy = jest.spyOn(document.body, "appendChild"); + + let capturedCallback; + const fakePicker = { setVisible: jest.fn() }; + const fakeGoogle = { + picker: { + ViewId: { FOLDERS: "folders" }, + Action: { PICKED: "picked", CANCEL: "cancel" }, + DocsView: jest.fn().mockImplementation(() => ({ + setMimeTypes: jest.fn().mockReturnThis(), + setSelectFolderEnabled: jest.fn().mockReturnThis(), + setIncludeFolders: jest.fn().mockReturnThis(), + })), + PickerBuilder: jest.fn().mockImplementation(() => ({ + addView: jest.fn().mockReturnThis(), + setOAuthToken: jest.fn().mockReturnThis(), + setDeveloperKey: jest.fn().mockReturnThis(), + setAppId: jest.fn().mockReturnThis(), + setCallback: jest.fn(function (cb) { + capturedCallback = cb; + return this; + }), + build: jest.fn(() => fakePicker), + })), + }, + }; + const fakeGapi = { + load: jest.fn((_name, { callback }) => callback()), + }; + + // Simulate the real