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
32 changes: 32 additions & 0 deletions firestore-send-email/functions/__tests__/e2e/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as admin from "firebase-admin";

export const TEST_COLLECTIONS = ["mail", "templates"] as const;

// Initialize Firebase Admin once for all e2e tests
beforeAll(() => {
if (!admin.apps.length) {
admin.initializeApp({ projectId: "demo-test" });
}
process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080";
});

/**
* Clears all documents from test collections.
* Call this in beforeEach to ensure clean state between tests.
*/
export async function clearCollections() {
const db = admin.firestore();
for (const collection of TEST_COLLECTIONS) {
const snapshot = await db.collection(collection).get();
const batch = db.batch();
snapshot.docs.forEach((doc) => batch.delete(doc.ref));
await batch.commit();
}
}

/**
* Gets the test email address from environment or returns default.
*/
export function getTestEmail() {
return process.env.TEST_EMAIL || "test@example.com";
}
144 changes: 144 additions & 0 deletions firestore-send-email/functions/__tests__/e2e/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* E2E tests for attachment validation edge cases.
*
* Tests that the extension handles various attachment formats gracefully:
* - Missing attachments field
* - Null attachments
* - Single attachment object (should normalize to array)
* - Empty objects in attachments array (should be filtered out)
*
* Run with: npm run test:e2e
*/

import * as admin from "firebase-admin";
import { clearCollections, getTestEmail } from "./setup";

const TEST_TEMPLATE = {
name: "validation_test_template",
subject: "Test Subject {{id}}",
text: "Test content for {{id}}",
html: "<p>Test content for {{id}}</p>",
};

describe.skip("Attachment validation edge cases", () => {
beforeEach(async () => {
await clearCollections();

const db = admin.firestore();
await db.collection("templates").doc(TEST_TEMPLATE.name).set({
subject: TEST_TEMPLATE.subject,
text: TEST_TEMPLATE.text,
html: TEST_TEMPLATE.html,
});
});

test("should process template email without attachments field", async () => {
const db = admin.firestore();

const testData = {
template: {
name: TEST_TEMPLATE.name,
data: { id: "test-1" },
},
to: getTestEmail(),
};

const docRef = db.collection("mail").doc("test-no-attachments");
await docRef.set(testData);

await new Promise((resolve) => setTimeout(resolve, 2000));

const doc = await docRef.get();
const updatedData = doc.data();

expect(updatedData?.delivery.state).toBe("SUCCESS");
expect(updatedData?.delivery.error).toBeNull();
});

test("should process template email with null message attachments", async () => {
const db = admin.firestore();

const testData = {
template: {
name: TEST_TEMPLATE.name,
data: { id: "test-2" },
},
message: {
attachments: null,
},
to: getTestEmail(),
};

const docRef = db
.collection("emailCollection")
.doc("test-null-attachments");
await docRef.set(testData);

await new Promise((resolve) => setTimeout(resolve, 2000));

const doc = await docRef.get();
const updatedData = doc.data();

expect(updatedData?.delivery.state).toBe("SUCCESS");
expect(updatedData?.delivery.error).toBeNull();
});

test("should normalize single attachment object to array", async () => {
const db = admin.firestore();

const testData = {
template: {
name: TEST_TEMPLATE.name,
data: { id: "test-3" },
},
message: {
attachments: {
filename: "test.txt",
content: "test content",
},
},
to: getTestEmail(),
};

const docRef = db
.collection("emailCollection")
.doc("test-object-attachment");
await docRef.set(testData);

await new Promise((resolve) => setTimeout(resolve, 2000));

const doc = await docRef.get();
const updatedData = doc.data();

expect(updatedData?.delivery.state).toBe("SUCCESS");
expect(updatedData?.delivery.error).toBeNull();
});

test("should filter out empty objects in attachments array", async () => {
const db = admin.firestore();

const testData = {
template: {
name: TEST_TEMPLATE.name,
data: { id: "test-4" },
},
message: {
attachments: [{}],
},
to: getTestEmail(),
};

const docRef = db
.collection("emailCollection")
.doc("test-empty-attachment");
await docRef.set(testData);

await new Promise((resolve) => setTimeout(resolve, 2000));

const doc = await docRef.get();
const updatedData = doc.data();

expect(updatedData?.delivery.state).toBe("SUCCESS");
expect(updatedData?.delivery.error).toBeNull();
});
});
129 changes: 112 additions & 17 deletions firestore-send-email/functions/__tests__/prepare-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ class MockTemplates {
text: undefined,
subject: "Template Subject",
};
case "template-with-object-attachment":
// Simulates a template that returns attachments as an object instead of array
return {
html: "<h1>Template HTML</h1>",
subject: "Template Subject",
attachments: { filename: "report.pdf" },
};
case "template-with-null-attachments":
return {
html: "<h1>Template HTML</h1>",
subject: "Template Subject",
attachments: null,
};
default:
return {};
}
Expand Down Expand Up @@ -351,18 +364,13 @@ describe("preparePayload Template Merging", () => {
expect(result.message.subject).toBe("Template Subject");
});

it("should handle incorrectly formatted attachments object", async () => {
it("should filter out empty attachment objects with only null values", async () => {
const payload = {
to: "tester@gmx.at",
to: "test@example.com",
template: {
name: "med_order_reply_greimel",
name: "html-only-template",
data: {
address: "Halbenrain 140 Graz",
doctorName: "Dr. Andreas",
openingHours: "Mo., Mi., Fr. 8:00-12:00Di., Do. 10:30-15:30",
orderText: "Some stuff i need",
userName: "Pfeiler ",
name: "med_order_reply_greimel",
name: "Test User",
},
},
message: {
Expand All @@ -372,20 +380,20 @@ describe("preparePayload Template Merging", () => {
text: null,
},
],
subject: "Bestellbestätigung",
subject: "Test Subject",
},
};

const result = await preparePayload(payload);

// Should convert attachments to an empty array since the format is incorrect
// Empty attachment objects should be filtered out
expect(result.message.attachments).toEqual([]);
expect(result.message.subject).toBe("Bestellbestätigung");
expect(result.to).toEqual(["tester@gmx.at"]);
expect(result.message.subject).toBe("Template Subject");
expect(result.to).toEqual(["test@example.com"]);
});

describe("attachment validation", () => {
it("should handle non-array attachments", async () => {
it("should throw clear error for string attachments", async () => {
const payload = {
to: "test@example.com",
message: {
Expand All @@ -395,10 +403,33 @@ describe("preparePayload Template Merging", () => {
},
};

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

it("should throw clear error for invalid attachment httpHeaders", async () => {
const payload = {
to: "test@example.com",
message: {
subject: "Test Subject",
text: "Test text",
attachments: [
{
filename: "test.txt",
href: "https://example.com",
httpHeaders: "invalid",
},
],
},
};

await expect(preparePayload(payload)).rejects.toThrow(
"Invalid message configuration: Field 'message.attachments.0.httpHeaders' must be a map"
);
});

it("should handle null attachments", async () => {
it("should handle null attachments as no attachments", async () => {
const payload = {
to: "test@example.com",
message: {
Expand All @@ -408,7 +439,24 @@ describe("preparePayload Template Merging", () => {
},
};

await expect(preparePayload(payload)).rejects.toThrow();
const result = await preparePayload(payload);
expect(result.message.attachments).toBeUndefined();
});

it("should normalize single attachment object to array", async () => {
const payload = {
to: "test@example.com",
message: {
subject: "Test Subject",
text: "Test text",
attachments: { filename: "test.txt", content: "test content" },
},
};

const result = await preparePayload(payload);
expect(result.message.attachments).toEqual([
{ filename: "test.txt", content: "test content" },
]);
});

it("should handle undefined attachments", async () => {
Expand Down Expand Up @@ -439,4 +487,51 @@ describe("preparePayload Template Merging", () => {
expect(result.message.attachments).toEqual([]);
});
});

describe("template-rendered attachments", () => {
it("should normalize template-returned attachment object to array", async () => {
// This tests the exact scenario from issue #2550 where a template
// returns attachments as an object instead of an array
const payload = {
to: "test@example.com",
template: {
name: "template-with-object-attachment",
data: {},
},
};

const result = await preparePayload(payload);
expect(result.message.attachments).toEqual([{ filename: "report.pdf" }]);
});

it("should handle template-returned null attachments", async () => {
const payload = {
to: "test@example.com",
template: {
name: "template-with-null-attachments",
data: {},
},
};

const result = await preparePayload(payload);
expect(result.message.attachments).toEqual([]);
});

it("should process template-only payload without message field", async () => {
// Matches the user's payload structure - template only, no message field
const payload = {
to: "test@example.com",
template: {
name: "html-only-template",
data: {
someField: "value",
},
},
};

const result = await preparePayload(payload);
expect(result.message.html).toBe("<h1>Template HTML</h1>");
expect(result.message.subject).toBe("Template Subject");
});
});
});
Loading
Loading