Skip to content
Open
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
121 changes: 80 additions & 41 deletions functions/packages/feed-form/src/__tests__/feed-form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,96 @@
buildFeedRow,
buildFeedRows,
SheetCol,
writeToSheet,
} from "../impl/feed-form-impl";
import {type FeedSubmissionFormRequestBody} from "../impl/types";
import * as logger from "firebase-functions/logger";
import {sampleRequestBodyGTFS, sampleRequestBodyGTFSRT} from "../impl/__mocks__/feed-submission-form-request-body.mock";

Check failure on line 8 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

This line has a length of 120. Maximum allowed is 80
import {HttpsError} from "firebase-functions/v2/https";

const sampleRequestBodyGTFS: FeedSubmissionFormRequestBody = {
name: "Sample Feed",
isOfficialProducer: "yes",
isOfficialFeed: "yes",
dataType: "gtfs",
transitProviderName: "Sample Transit Provider",
feedLink: "https://example.com/feed",
isUpdatingFeed: "yes",
oldFeedLink: "https://example.com/old-feed",
licensePath: "/path/to/license",
country: "USA",
region: "California",
municipality: "San Francisco",
tripUpdates: "",
vehiclePositions: "",
serviceAlerts: "",
gtfsRelatedScheduleLink: "https://example.com/gtfs-schedule",
authType: "None - 0",
authSignupLink: "https://example.com/signup",
authParameterName: "auth_token",
dataProducerEmail: "[email protected]",
isInterestedInQualityAudit: "yes",
userInterviewEmail: "[email protected]",
whatToolsUsedText: "Google Sheets, Node.js",
hasLogoPermission: "yes",
unofficialDesc: "For research purposes",
updateFreq: "every month",
emptyLicenseUsage: "unsure",
};
jest.mock("google-spreadsheet", () => ({
GoogleSpreadsheet: jest.fn().mockImplementation(() => ({
loadInfo: jest.fn(),
sheetsByIndex: [
{
addRows: jest.fn(),
},
],
})),
}));
jest.mock("google-auth-library", () => ({
GoogleAuth: jest.fn(),
}));

const mockCreateGithubIssue = jest.fn().mockResolvedValue("https://github.com/issue/1");
const mockSendSlackWebhook = jest.fn().mockResolvedValue(undefined);

jest.mock("../impl/utils/github-issue", () => ({
createGithubIssue: (...args: any[]) => mockCreateGithubIssue(...args),

Check warning on line 29 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
}));
jest.mock("../impl/utils/slack", () => ({
sendSlackWebhook: (...args: any[]) => mockSendSlackWebhook(...args),

Check warning on line 32 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
}));

jest.spyOn(logger, "error").mockImplementation(() => {});

Check failure on line 35 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected empty arrow function

const sampleRequestBodyGTFSRT: FeedSubmissionFormRequestBody = {
...sampleRequestBodyGTFS,
dataType: "gtfs_rt",
feedLink: "",
tripUpdates: "https://example.com/gtfs-realtime-trip-update",
vehiclePositions: "https://example.com/gtfs-realtime-vehicle-position",
serviceAlerts: "https://example.com/gtfs-realtime-service-alerts",
oldTripUpdates: "https://example.com/old-feed-tu",
oldServiceAlerts: "https://example.com/old-feed-sa",
oldVehiclePositions: "https://example.com/old-feed-vp",
};
const defaultEnv = process.env;

describe("Feed Form Implementation", () => {

Check failure on line 39 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Block must not be padded by blank lines

beforeAll(() => {
const mockDate = new Date("2023-08-01T00:00:00Z");
jest.spyOn(global, "Date").mockImplementation(() => mockDate);
});

beforeEach(() => {

Check failure on line 46 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Expected indentation of 2 spaces but found 4
jest.clearAllMocks();
process.env = { ...defaultEnv };

Check failure on line 48 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

There should be no space before '}'

Check failure on line 48 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

There should be no space after '{'
process.env.FEED_SUBMIT_GOOGLE_SHEET_ID = "sheet123";
process.env.GCLOUD_PROJECT = "mobility-feeds-prod";
process.env.GITHUB_TOKEN = "token";
process.env.SLACK_WEBHOOK_URL = "https://slack";
});

afterAll(() => {
process.env = defaultEnv;
});

it("should throw HttpsError if sheet ID is not defined", async () => {
process.env.FEED_SUBMIT_GOOGLE_SHEET_ID = "";
const mockRequest = {
auth: { uid: "user1" },

Check failure on line 62 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

There should be no space before '}'

Check failure on line 62 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

There should be no space after '{'
data: sampleRequestBodyGTFS,
};
await expect(writeToSheet(mockRequest as any)).rejects.toThrow(HttpsError);

Check warning on line 65 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
expect(logger.error).toHaveBeenCalledWith(
"Error writing to sheet:",
expect.any(HttpsError)
);
});

it("writeToSheet writes to sheet, creates github issue, sends slack, returns success", async () => {

Check failure on line 72 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

This line has a length of 102. Maximum allowed is 80
const mockRequest = {
auth: { uid: "user1" },

Check failure on line 74 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

There should be no space after '{'
data: sampleRequestBodyGTFS,
};
const result = await writeToSheet(mockRequest as any);

Check warning on line 77 in functions/packages/feed-form/src/__tests__/feed-form.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
const { GoogleSpreadsheet } = require("google-spreadsheet");
expect(GoogleSpreadsheet).toHaveBeenCalledWith("sheet123", expect.anything());
const doc = GoogleSpreadsheet.mock.results[0].value;
expect(doc.loadInfo).toHaveBeenCalled();
expect(doc.sheetsByIndex[0].addRows).toHaveBeenCalledWith(
expect.any(Array),
{ insert: true }
);
expect(mockCreateGithubIssue).toHaveBeenCalled();
expect(mockSendSlackWebhook).toHaveBeenCalledWith(
"sheet123",
"https://github.com/issue/1",
true
);
expect(result).toEqual({ message: "Data written to the new sheet successfully!" });
});

it("should build the rows if gtfs schedule", () => {
buildFeedRows(sampleRequestBodyGTFS, "user123");
const expectedRows = [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,58 @@
import {buildGithubIssueBody} from "../impl/feed-form-impl";
import {FeedSubmissionFormRequestBody} from "../impl/types";
import { createGithubIssue, buildGithubIssueBody } from "../../impl/utils/github-issue";
import axios from "axios";
import * as logger from "firebase-functions/logger";
import { isValidZipUrl, isValidZipDownload } from "../../impl/utils/url-parse";
import {FeedSubmissionFormRequestBody} from "../../impl/types";
import {sampleRequestBodyGTFS} from "../../impl/__mocks__/feed-submission-form-request-body.mock";

jest.mock("axios");
jest.mock("firebase-functions/logger");
jest.mock("../../impl/utils/url-parse");

const mockedAxios = axios as jest.Mocked<typeof axios>;
const mockedIsValidZipUrl = isValidZipUrl as jest.Mock;
const mockedIsValidZipDownload = isValidZipDownload as jest.Mock;

describe("createGithubIssue", () => {
const formData = {
sampleRequestBodyGTFS
} as any;

Check warning on line 19 in functions/packages/feed-form/src/__tests__/utils/github-issue.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
Comment on lines +17 to +19
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formData object is incorrectly structured. It creates {sampleRequestBodyGTFS: {...}} instead of spreading the properties. This should be const formData = sampleRequestBodyGTFS as any; to correctly reference the mock data.

Suggested change
const formData = {
sampleRequestBodyGTFS
} as any;
const formData = sampleRequestBodyGTFS as any;

Copilot uses AI. Check for mistakes.
const spreadsheetId = "sheet123";
const githubToken = "token123";

beforeEach(() => {
jest.clearAllMocks();
mockedIsValidZipUrl.mockReturnValue(true);
mockedIsValidZipDownload.mockResolvedValue(true);
});

it("creates an issue and returns the URL", async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { html_url: "https://github.com/issue/1" } });
const url = await createGithubIssue(formData, spreadsheetId, githubToken);
expect(url).toBe("https://github.com/issue/1");
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining("github.com/repos"),
expect.objectContaining({ title: expect.any(String) }),
expect.objectContaining({ headers: expect.objectContaining({ Authorization: expect.any(String) }) })
);
});

it("returns empty string and logs error on failure", async () => {
mockedAxios.post.mockRejectedValueOnce(new Error("fail"));
const url = await createGithubIssue(formData, spreadsheetId, githubToken);
expect(url).toBe("");
expect(logger.error).toHaveBeenCalled();
});

it("adds 'invalid' label if zip is invalid", async () => {
mockedIsValidZipUrl.mockReturnValue(false);
mockedIsValidZipDownload.mockResolvedValue(false);
mockedAxios.post.mockResolvedValueOnce({ data: { html_url: "https://github.com/issue/2" } });
await createGithubIssue(formData, spreadsheetId, githubToken);
const call = mockedAxios.post.mock.calls[0][1] as any;

Check warning on line 52 in functions/packages/feed-form/src/__tests__/utils/github-issue.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
expect(call.labels).toContain("invalid");
});
});

describe("buildGithubIssueBody", () => {
const spreadsheetId = "testSpreadsheetId";
Expand Down Expand Up @@ -203,3 +256,4 @@
expect(buildGithubIssueBody(formData, spreadsheetId)).toBe(expectedContent);
});
});

42 changes: 42 additions & 0 deletions functions/packages/feed-form/src/__tests__/utils/slack.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { sendSlackWebhook } from "../../impl/utils/slack";
import axios from "axios";
import * as logger from "firebase-functions/logger";

jest.mock("axios");
jest.mock("firebase-functions/logger");

describe("sendSlackWebhook", () => {
const spreadsheetId = "sheet123";
const githubIssueUrl = "https://github.com/issue/1";
const oldEnv = process.env;

beforeEach(() => {
jest.clearAllMocks();
process.env = { ...oldEnv, SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/abc" };
});

afterAll(() => {
process.env = oldEnv;
});

it("sends a Slack message with correct payload", async () => {
(axios.post as jest.Mock).mockResolvedValueOnce({});
await sendSlackWebhook(spreadsheetId, githubIssueUrl, true);
expect(axios.post).toHaveBeenCalledWith(
process.env.SLACK_WEBHOOK_URL,
expect.objectContaining({ blocks: expect.any(Array) })
);
});

it("logs error if webhook URL is not set", async () => {
process.env.SLACK_WEBHOOK_URL = "";
await sendSlackWebhook(spreadsheetId, githubIssueUrl, false);
expect(logger.error).toHaveBeenCalledWith("Slack webhook URL is not defined");
});

it("logs error if axios fails", async () => {
(axios.post as jest.Mock).mockRejectedValueOnce(new Error("fail"));
await sendSlackWebhook(spreadsheetId, githubIssueUrl, false);
expect(logger.error).toHaveBeenCalled();
});
});
60 changes: 60 additions & 0 deletions functions/packages/feed-form/src/__tests__/utils/url-parse.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { isValidZipUrl, isValidZipDownload } from "../../impl/utils/url-parse";
import axios from "axios";

jest.mock("axios");
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe("isValidZipUrl", () => {
it("returns true for valid .zip URL", () => {
expect(isValidZipUrl("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).toBe(true);
expect(isValidZipUrl("https://file-examples.com/wp-content/storage/2017/02/zip_5MB.zip")).toBe(true);
});

it("returns false for non-.zip URL", () => {
expect(isValidZipUrl("https://file-examples.com/wp-content/storage/2017/02/file_example_CSV_5000.csv")).toBe(false);
expect(isValidZipUrl("https://file-examples.com/wp-content/storage/2017/02/file_example_JSON_1kb.json")).toBe(false);
});

it("returns false for invalid or empty input", () => {
expect(isValidZipUrl("")).toBe(false);
expect(isValidZipUrl(undefined)).toBe(false);
expect(isValidZipUrl(null)).toBe(false);
expect(isValidZipUrl("not a url")).toBe(false);
});
});

describe("isValidZipDownload", () => {
afterEach(() => jest.resetAllMocks());

it("returns true if content-type includes zip", async () => {
mockedAxios.head.mockResolvedValueOnce({
headers: { "content-type": "application/zip" }
} as any);

Check warning on line 32 in functions/packages/feed-form/src/__tests__/utils/url-parse.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
await expect(isValidZipDownload("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).resolves.toBe(true);
});

it("returns true if content-disposition includes zip", async () => {
mockedAxios.head.mockResolvedValueOnce({
headers: { "content-disposition": "attachment; filename=foo.zip" }
} as any);

Check warning on line 39 in functions/packages/feed-form/src/__tests__/utils/url-parse.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
await expect(isValidZipDownload("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).resolves.toBe(true);
});

it("returns false if neither header includes zip", async () => {
mockedAxios.head.mockResolvedValueOnce({
headers: { "content-type": "text/plain" }
} as any);

Check warning on line 46 in functions/packages/feed-form/src/__tests__/utils/url-parse.spec.ts

View workflow job for this annotation

GitHub Actions / deploy-web-app / Build & Deploy

Unexpected any. Specify a different type
await expect(isValidZipDownload("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).resolves.toBe(false);
});

it("returns false for invalid/empty url", async () => {
await expect(isValidZipDownload("")).resolves.toBe(false);
await expect(isValidZipDownload(undefined)).resolves.toBe(false);
await expect(isValidZipDownload(null)).resolves.toBe(false);
});

it("returns false if axios throws", async () => {
mockedAxios.head.mockRejectedValueOnce(new Error("Network error"));
await expect(isValidZipDownload("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).resolves.toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { FeedSubmissionFormRequestBody } from "../types";

export const sampleRequestBodyGTFS: FeedSubmissionFormRequestBody = {
name: "Sample Feed",
isOfficialProducer: "yes",
isOfficialFeed: "yes",
dataType: "gtfs",
transitProviderName: "Sample Transit Provider",
feedLink: "https://example.com/feed",
isUpdatingFeed: "yes",
oldFeedLink: "https://example.com/old-feed",
licensePath: "/path/to/license",
country: "USA",
region: "California",
municipality: "San Francisco",
tripUpdates: "",
vehiclePositions: "",
serviceAlerts: "",
gtfsRelatedScheduleLink: "https://example.com/gtfs-schedule",
authType: "None - 0",
authSignupLink: "https://example.com/signup",
authParameterName: "auth_token",
dataProducerEmail: "[email protected]",
isInterestedInQualityAudit: "yes",
userInterviewEmail: "[email protected]",
whatToolsUsedText: "Google Sheets, Node.js",
hasLogoPermission: "yes",
unofficialDesc: "For research purposes",
updateFreq: "every month",
emptyLicenseUsage: "unsure",
};

export const sampleRequestBodyGTFSRT: FeedSubmissionFormRequestBody = {
...sampleRequestBodyGTFS,
dataType: "gtfs_rt",
feedLink: "",
tripUpdates: "https://example.com/gtfs-realtime-trip-update",
vehiclePositions: "https://example.com/gtfs-realtime-vehicle-position",
serviceAlerts: "https://example.com/gtfs-realtime-service-alerts",
oldTripUpdates: "https://example.com/old-feed-tu",
oldServiceAlerts: "https://example.com/old-feed-sa",
oldVehiclePositions: "https://example.com/old-feed-vp",
};
Loading
Loading