Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/firebase-deploy-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
130 changes: 130 additions & 0 deletions __tests__/experiment-creation.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
91 changes: 91 additions & 0 deletions __tests__/google-picker.test.js
Original file line number Diff line number Diff line change
@@ -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 <script src="apis.google.com/js/api.js"> tag: once
// it's appended to the DOM, it would set window.gapi and fire "load".
appendChildSpy.mockImplementation((node) => {
if (node.tagName === "SCRIPT") {
window.gapi = fakeGapi;
window.google = fakeGoogle;
node.dispatchEvent(new Event("load"));
}
return node;
});

// First call: user cancels the picker.
const firstPick = pickDriveFolder({
accessToken: "token-1",
apiKey: "key",
appId: "app-id",
});
await flushMicrotasks();
expect(typeof capturedCallback).toBe("function");
capturedCallback({ action: fakeGoogle.picker.Action.CANCEL });
await expect(firstPick).resolves.toBeNull();

// Second call: user picks a folder.
const secondPick = pickDriveFolder({
accessToken: "token-2",
apiKey: "key",
appId: "app-id",
});
await flushMicrotasks();
capturedCallback({
action: fakeGoogle.picker.Action.PICKED,
docs: [{ id: "folder-1", name: "My Folder" }],
});
await expect(secondPick).resolves.toEqual({
id: "folder-1",
name: "My Folder",
});

const scriptAppends = appendChildSpy.mock.calls.filter(
([node]) => node.tagName === "SCRIPT"
);
expect(scriptAppends).toHaveLength(1);
});
});
4 changes: 4 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
{
"source": "/api/disconnectprovider",
"function": "disconnectprovider"
},
{
"source": "/api/getprovideraccesstoken",
"function": "getprovideraccesstoken"
}
]
},
Expand Down
47 changes: 47 additions & 0 deletions functions/src/__tests__/create-experiment-emulator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ function createMockDriveServer() {
}
return null;
},
getParents: (name) => {
for (const f of filesById.values()) {
if (f.name === name) return f.parents;
}
return null;
},
forceStatus: (name, status) => forcedStatus.set(name, status),
reset: () => {
filesById.clear();
Expand Down Expand Up @@ -370,3 +376,44 @@ describe("9. createExperiment Drive container-creation failure", () => {
expect(userData?.experiments || []).toEqual([]);
});
});

describe("10. createExperiment with parentFolderId", () => {
it("creates the experiment folder directly under the given parent, skipping the DataPipe-root lookup entirely", async () => {
const { uid, idToken } = await signUpEmulatorUser();
await seedGdriveUser(uid);
const title = `Case10 ${randomUUID()}`;
const parentFolderId = `picker-folder-${randomUUID()}`;

const { status, body } = await callCreateExperiment({
provider: "gdrive",
title,
idToken,
uid,
parentFolderId,
});

expect(status).toBe(200);
const folderId = mockDrive.getFolderId(title);
expect(typeof folderId).toBe("string");
expect(body.providerContainer).toEqual({ provider: "gdrive", folderId });
expect(mockDrive.getParents(title)).toEqual([parentFolderId]);

// The DataPipe-root find-or-create is skipped entirely when a
// researcher-chosen parent is supplied.
expect(mockDrive.getCreateCount("DataPipe")).toBe(0);
});

it("still lands under the DataPipe root when parentFolderId is omitted (regression)", async () => {
const { uid, idToken } = await signUpEmulatorUser();
await seedGdriveUser(uid);
const title = `Case10b ${randomUUID()}`;

const { status } = await callCreateExperiment({ provider: "gdrive", title, idToken, uid });

expect(status).toBe(200);
const dataPipeId = mockDrive.getFolderId("DataPipe");
expect(typeof dataPipeId).toBe("string");
expect(mockDrive.getParents(title)).toEqual([dataPipeId]);
expect(mockDrive.getCreateCount("DataPipe")).toBe(1);
});
});
Loading