Skip to content
Draft
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
906 changes: 906 additions & 0 deletions firestore-send-email/functions/__tests__/delivery.integration.test.ts

Large diffs are not rendered by default.

231 changes: 227 additions & 4 deletions firestore-send-email/functions/__tests__/prepare-payload.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
// Mock the config module before importing prepare-payload
jest.mock("../src/config", () => ({
default: {
usersCollection: "users",
},
__esModule: true,
}));

import { preparePayload, setDependencies } from "../src/prepare-payload";

class MockTemplates {
Expand Down Expand Up @@ -48,6 +56,12 @@ class MockTemplates {
text: undefined,
subject: "Template Subject",
};
case "template-with-object-attachments":
return {
html: "<h1>Template HTML</h1>",
subject: "Template Subject",
attachments: { filename: "bad.pdf", content: "test" },
};
default:
return {};
}
Expand Down Expand Up @@ -144,6 +158,20 @@ describe("preparePayload Template Merging", () => {
expect(result.message.attachments).toEqual([{ filename: "template.pdf" }]);
});

it("should throw formatted error when template returns object attachments (not array)", async () => {
const payload = {
to: "test@example.com",
template: {
name: "template-with-object-attachments",
data: {},
},
};

await expect(preparePayload(payload)).rejects.toThrow(
"Field 'message.attachments' must be an array"
);
});

it("should gracefully handle template with no content", async () => {
const payload = {
to: "test@example.com",
Expand Down Expand Up @@ -251,15 +279,16 @@ describe("preparePayload Template Merging", () => {
expect(result.message.subject).toBe("Direct Subject");
});

it("should handle empty message object", async () => {
it("should reject empty message object without template or sendGrid", async () => {
const payload = {
to: "test@example.com",
message: {},
};

const result = await preparePayload(payload);

expect(result.message).toEqual({});
// Empty message fails on subject requirement first
await expect(preparePayload(payload)).rejects.toThrow(
"Invalid message configuration"
);
});

it("should handle template with null values", async () => {
Expand Down Expand Up @@ -440,3 +469,197 @@ describe("preparePayload Template Merging", () => {
});
});
});

describe("preparePayload", () => {
it("should throw error attachments object not in an array", async () => {
const payload = {
to: "test@example.com",
message: {
subject: "Test Subject",
text: "Test text",
attachments: {
filename: "test.txt",
content: "test",
},
},
};

await expect(preparePayload(payload)).rejects.toThrow(
"Invalid message configuration: Field 'message.attachments' must be an array"
);
});
});

describe("preparePayload UID resolution", () => {
const createMockDocSnapshot = (
id: string,
email: string | null,
exists: boolean = true
) => ({
id,
exists,
get: (field: string) => (field === "email" ? email : undefined),
});

beforeEach(() => {
jest.clearAllMocks();
});

it("should resolve UIDs to emails successfully", async () => {
const mockGetAll = jest
.fn()
.mockResolvedValue([
createMockDocSnapshot("uid1", "user1@example.com"),
createMockDocSnapshot("uid2", "user2@example.com"),
]);

const mockDb = {
getAll: mockGetAll,
collection: jest.fn().mockReturnValue({
doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
}),
};

setDependencies(mockDb, null);

const payload = {
toUids: ["uid1", "uid2"],
message: {
subject: "Test Subject",
text: "Test text",
},
};

const result = await preparePayload(payload);

expect(result.to).toContain("user1@example.com");
expect(result.to).toContain("user2@example.com");
});

it("should handle mixed valid and invalid UIDs", async () => {
const mockGetAll = jest.fn().mockResolvedValue([
createMockDocSnapshot("uid1", "user1@example.com"),
createMockDocSnapshot("uid2", null), // User exists but no email
createMockDocSnapshot("uid3", "user3@example.com"),
createMockDocSnapshot("uid4", null, false), // User doesn't exist
]);

const mockDb = {
getAll: mockGetAll,
collection: jest.fn().mockReturnValue({
doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
}),
};

setDependencies(mockDb, null);

const payload = {
toUids: ["uid1", "uid2", "uid3", "uid4"],
message: {
subject: "Test Subject",
text: "Test text",
},
};

const result = await preparePayload(payload);

// Only valid emails should be included
expect(result.to).toEqual(["user1@example.com", "user3@example.com"]);
});

it("should handle all UIDs failing to resolve", async () => {
const mockGetAll = jest
.fn()
.mockResolvedValue([
createMockDocSnapshot("uid1", null, false),
createMockDocSnapshot("uid2", null, false),
]);

const mockDb = {
getAll: mockGetAll,
collection: jest.fn().mockReturnValue({
doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
}),
};

setDependencies(mockDb, null);

const payload = {
toUids: ["uid1", "uid2"],
message: {
subject: "Test Subject",
text: "Test text",
},
};

const result = await preparePayload(payload);

// No emails resolved, so to should be empty
expect(result.to).toEqual([]);
});

it("should resolve UIDs across to, cc, and bcc", async () => {
const mockGetAll = jest
.fn()
.mockResolvedValue([
createMockDocSnapshot("uid1", "to@example.com"),
createMockDocSnapshot("uid2", "cc@example.com"),
createMockDocSnapshot("uid3", "bcc@example.com"),
]);

const mockDb = {
getAll: mockGetAll,
collection: jest.fn().mockReturnValue({
doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
}),
};

setDependencies(mockDb, null);

const payload = {
toUids: ["uid1"],
ccUids: ["uid2"],
bccUids: ["uid3"],
message: {
subject: "Test Subject",
text: "Test text",
},
};

const result = await preparePayload(payload);

expect(result.to).toEqual(["to@example.com"]);
expect(result.cc).toEqual(["cc@example.com"]);
expect(result.bcc).toEqual(["bcc@example.com"]);
});

it("should combine direct emails with resolved UIDs", async () => {
const mockGetAll = jest
.fn()
.mockResolvedValue([
createMockDocSnapshot("uid1", "resolved@example.com"),
]);

const mockDb = {
getAll: mockGetAll,
collection: jest.fn().mockReturnValue({
doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
}),
};

setDependencies(mockDb, null);

const payload = {
to: "direct@example.com",
toUids: ["uid1"],
message: {
subject: "Test Subject",
text: "Test text",
},
};

const result = await preparePayload(payload);

expect(result.to).toEqual(["direct@example.com", "resolved@example.com"]);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Mail = require("nodemailer/lib/mailer");
const { logger } = require("firebase-functions");

import { setSmtpCredentials, isSendGrid } from "../src/helpers";
import { setSmtpCredentials, isSendGrid } from "../src/transport";
import { AuthenticatonType, Config } from "../src/types";

const consoleLogSpy = jest.spyOn(logger, "warn").mockImplementation();
Expand Down
94 changes: 94 additions & 0 deletions firestore-send-email/functions/__tests__/ttl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Mock Timestamp class that mimics firebase-admin Timestamp
class MockTimestamp {
private _date: Date;

constructor(seconds: number, nanoseconds: number) {
this._date = new Date(seconds * 1000 + nanoseconds / 1000000);
}

static fromDate(date: Date): MockTimestamp {
return new MockTimestamp(Math.floor(date.getTime() / 1000), 0);
}

toDate(): Date {
return this._date;
}
}

// Mock firebase-admin/firestore module
jest.mock("firebase-admin/firestore", () => ({
Timestamp: MockTimestamp,
}));

import { calculateExpireAt } from "../src/ttl";
import { Timestamp } from "firebase-admin/firestore";

describe("calculateExpireAt", () => {
const baseDate = new Date("2024-01-15T12:00:00Z");

test("calculates hour expiration correctly", () => {
const baseTimestamp = Timestamp.fromDate(baseDate);
const result = calculateExpireAt(baseTimestamp, "hour", 2);
const expected = new Date("2024-01-15T14:00:00Z");
expect(result.toDate()).toEqual(expected);
});

test("calculates day expiration correctly", () => {
const baseTimestamp = Timestamp.fromDate(baseDate);
const result = calculateExpireAt(baseTimestamp, "day", 3);
const expected = new Date("2024-01-18T12:00:00Z");
expect(result.toDate()).toEqual(expected);
});

test("calculates week expiration correctly", () => {
const baseTimestamp = Timestamp.fromDate(baseDate);
const result = calculateExpireAt(baseTimestamp, "week", 2);
const expected = new Date("2024-01-29T12:00:00Z");
expect(result.toDate()).toEqual(expected);
});

test("calculates month expiration correctly", () => {
const baseTimestamp = Timestamp.fromDate(baseDate);
const result = calculateExpireAt(baseTimestamp, "month", 1);
const expected = new Date("2024-02-15T12:00:00Z");
expect(result.toDate()).toEqual(expected);
});

test("calculates year expiration correctly", () => {
const baseTimestamp = Timestamp.fromDate(baseDate);
const result = calculateExpireAt(baseTimestamp, "year", 1);
const expected = new Date("2025-01-15T12:00:00Z");
expect(result.toDate()).toEqual(expected);
});

test("throws error for unknown TTLExpireType", () => {
const baseTimestamp = Timestamp.fromDate(baseDate);
expect(() => calculateExpireAt(baseTimestamp, "invalid", 1)).toThrow(
"Unknown TTLExpireType: invalid"
);
});

test("handles zero value", () => {
const baseTimestamp = Timestamp.fromDate(baseDate);
const result = calculateExpireAt(baseTimestamp, "day", 0);
expect(result.toDate()).toEqual(baseDate);
});

test("handles large values", () => {
const baseTimestamp = Timestamp.fromDate(baseDate);
const result = calculateExpireAt(baseTimestamp, "day", 365);
// 2024 is a leap year (366 days), so 365 days from Jan 15 is Jan 14 next year
const expected = new Date("2025-01-14T12:00:00Z");
expect(result.toDate()).toEqual(expected);
});

test("handles month boundary correctly", () => {
// Jan 31 + 1 month - JavaScript Date overflows Feb
const jan31 = Timestamp.fromDate(new Date("2024-01-31T12:00:00Z"));
const result = calculateExpireAt(jan31, "month", 1);
// For 2024 (leap year), Jan 31 + 1 month = March 2 (Feb has 29 days, 31-29=2)
const resultDate = result.toDate();
expect(resultDate.getMonth()).toBe(2); // March (0-indexed)
expect(resultDate.getDate()).toBe(2);
});
});
Loading
Loading