Initial commit

This commit is contained in:
2025-08-15 01:03:38 -08:00
commit 959e52287a
35 changed files with 11807 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
testEnvironment: "node",
collectCoverage: true,
coverageDirectory: "../coverage",
coverageReporters: ["text", "lcov", "html"],
collectCoverageFrom: [
"../lib/**/*.js",
"../config/**/*.js",
"!../lib/**/*.test.js",
"!**/node_modules/**",
],
testMatch: ["**/__tests__/**/*.js", "**/?(*.)+(spec|test).js"],
setupFilesAfterEnv: ["<rootDir>/setup.js"],
testTimeout: 10000,
verbose: true,
};
+195
View File
@@ -0,0 +1,195 @@
const { describe, test, expect, beforeEach } = require("@jest/globals");
// Mock config
jest.mock("../../config", () => ({
errorHandling: {
maxRetries: 3,
},
}));
// Mock logger
jest.mock("../../lib/logger", () => ({
emailFailed: jest.fn(),
emailRetry: jest.fn(),
emailPermanentFailure: jest.fn(),
}));
const errorHandler = require("../../lib/errorHandler");
describe("ErrorHandler", () => {
beforeEach(() => {
jest.clearAllMocks();
errorHandler.clearRetries();
});
describe("classifyError", () => {
test("should classify authentication errors", () => {
const error = new Error("Invalid login credentials");
const result = errorHandler.classifyError(error);
expect(result).toBe("AUTH_ERROR");
});
test("should classify rate limit errors", () => {
const error = new Error("Rate limit exceeded");
const result = errorHandler.classifyError(error);
expect(result).toBe("RATE_LIMIT");
});
test("should classify network errors", () => {
const error = new Error("Connection timeout");
const result = errorHandler.classifyError(error);
expect(result).toBe("NETWORK_ERROR");
});
test("should classify recipient errors", () => {
const error = new Error("Invalid recipient address");
const result = errorHandler.classifyError(error);
expect(result).toBe("RECIPIENT_ERROR");
});
test("should classify message errors", () => {
const error = new Error("Message too large");
const result = errorHandler.classifyError(error);
expect(result).toBe("MESSAGE_ERROR");
});
test("should classify unknown errors", () => {
const error = new Error("Something unexpected happened");
const result = errorHandler.classifyError(error);
expect(result).toBe("UNKNOWN_ERROR");
});
});
describe("isRetryable", () => {
test("should mark retryable errors as retryable", () => {
expect(errorHandler.isRetryable("RATE_LIMIT")).toBe(true);
expect(errorHandler.isRetryable("NETWORK_ERROR")).toBe(true);
expect(errorHandler.isRetryable("UNKNOWN_ERROR")).toBe(true);
});
test("should mark non-retryable errors as non-retryable", () => {
expect(errorHandler.isRetryable("AUTH_ERROR")).toBe(false);
expect(errorHandler.isRetryable("RECIPIENT_ERROR")).toBe(false);
expect(errorHandler.isRetryable("MESSAGE_ERROR")).toBe(false);
});
});
describe("getRetryDelay", () => {
test("should calculate exponential backoff delay", () => {
const delay1 = errorHandler.getRetryDelay(1);
const delay2 = errorHandler.getRetryDelay(2);
const delay3 = errorHandler.getRetryDelay(3);
// Each delay should be roughly double the previous (with jitter)
expect(delay1).toBeGreaterThan(45000); // ~1 minute with jitter
expect(delay1).toBeLessThan(90000);
expect(delay2).toBeGreaterThan(90000); // ~2 minutes with jitter
expect(delay2).toBeLessThan(180000);
expect(delay3).toBeGreaterThan(180000); // ~4 minutes with jitter
expect(delay3).toBeLessThan(360000);
});
test("should cap delay at maximum", () => {
const delay = errorHandler.getRetryDelay(10); // Very high attempt number
expect(delay).toBeLessThanOrEqual(30 * 60 * 1000); // 30 minutes max
});
});
describe("handleError", () => {
test("should schedule retry for retryable errors", async () => {
const email = { subject: "Test", firmName: "Test Firm" };
const recipient = "test@example.com";
const error = new Error("Network timeout");
const transporter = {};
const result = await errorHandler.handleError(
email,
recipient,
error,
transporter
);
expect(result).toBe(true); // Indicates retry scheduled
const stats = errorHandler.getRetryStats();
expect(stats.totalFailed).toBe(1);
});
test("should not schedule retry for non-retryable errors", async () => {
const email = { subject: "Test", firmName: "Test Firm" };
const recipient = "test@example.com";
const error = new Error("Invalid login");
const transporter = {};
const result = await errorHandler.handleError(
email,
recipient,
error,
transporter
);
expect(result).toBe(false); // Indicates permanent failure
const stats = errorHandler.getRetryStats();
expect(stats.totalFailed).toBe(0);
});
test("should not retry after max attempts reached", async () => {
const email = { subject: "Test", firmName: "Test Firm" };
const recipient = "test@example.com";
const error = new Error("Network timeout");
const transporter = {};
// Schedule maximum retries
await errorHandler.handleError(email, recipient, error, transporter);
await errorHandler.handleError(email, recipient, error, transporter);
await errorHandler.handleError(email, recipient, error, transporter);
// This should be a permanent failure
const result = await errorHandler.handleError(
email,
recipient,
error,
transporter
);
expect(result).toBe(false);
});
});
describe("getRetryStats", () => {
test("should return correct retry statistics", () => {
// Initially no retries
let stats = errorHandler.getRetryStats();
expect(stats.totalFailed).toBe(0);
expect(stats.pendingRetries).toBe(0);
expect(stats.readyToRetry).toBe(0);
// Add a retry item
errorHandler.failedEmails.push({
recipient: "test@example.com",
retryAt: Date.now() + 60000, // 1 minute from now
});
stats = errorHandler.getRetryStats();
expect(stats.totalFailed).toBe(1);
expect(stats.pendingRetries).toBe(1);
expect(stats.readyToRetry).toBe(0);
});
});
describe("clearRetries", () => {
test("should clear all retry data", () => {
// Add some retry data
errorHandler.failedEmails.push({ recipient: "test@example.com" });
errorHandler.retryAttempts.set("test-key", 2);
errorHandler.clearRetries();
expect(errorHandler.failedEmails).toHaveLength(0);
expect(errorHandler.retryAttempts.size).toBe(0);
});
});
});
+160
View File
@@ -0,0 +1,160 @@
const { describe, test, expect, beforeEach } = require("@jest/globals");
// Mock config
jest.mock("../../config", () => ({
app: {
delayMinutes: 5,
},
}));
// Mock logger
jest.mock("../../lib/logger", () => ({
rateLimitPause: jest.fn(),
}));
const rateLimiter = require("../../lib/rateLimiter");
describe("RateLimiter", () => {
beforeEach(() => {
jest.clearAllMocks();
rateLimiter.reset();
});
describe("getRandomDelay", () => {
test("should return delay within expected range", () => {
const delay = rateLimiter.getRandomDelay();
// Base delay is 5 minutes = 300,000ms
// With 20% variance and jitter, expect roughly 240,000 to 390,000ms
expect(delay).toBeGreaterThan(200000);
expect(delay).toBeLessThan(400000);
});
test("should return different delays each time (due to randomization)", () => {
const delay1 = rateLimiter.getRandomDelay();
const delay2 = rateLimiter.getRandomDelay();
const delay3 = rateLimiter.getRandomDelay();
// Extremely unlikely to be identical due to randomization
expect(delay1).not.toBe(delay2);
expect(delay2).not.toBe(delay3);
});
});
describe("shouldPause", () => {
test("should not pause initially", () => {
expect(rateLimiter.shouldPause()).toBe(false);
});
test("should pause after sending many emails in short time", () => {
// Simulate sending 20 emails in 1 hour (exceeds 15/hour limit)
rateLimiter.sentCount = 20;
rateLimiter.startTime = Date.now() - 60 * 60 * 1000; // 1 hour ago
expect(rateLimiter.shouldPause()).toBe(true);
});
test("should pause every 50 emails", () => {
rateLimiter.sentCount = 50;
expect(rateLimiter.shouldPause()).toBe(true);
rateLimiter.sentCount = 100;
expect(rateLimiter.shouldPause()).toBe(true);
});
test("should not pause if under rate limit", () => {
// Simulate sending 10 emails in 1 hour (under 15/hour limit)
rateLimiter.sentCount = 10;
rateLimiter.startTime = Date.now() - 60 * 60 * 1000; // 1 hour ago
expect(rateLimiter.shouldPause()).toBe(false);
});
});
describe("getPauseDuration", () => {
test("should return pause duration around 30 minutes", () => {
const duration = rateLimiter.getPauseDuration();
// Base pause is 30 minutes = 1,800,000ms
// With randomization, expect 30-40 minutes
expect(duration).toBeGreaterThan(30 * 60 * 1000);
expect(duration).toBeLessThan(40 * 60 * 1000);
});
});
describe("getNextSendDelay", () => {
test("should increment sent count", async () => {
const initialCount = rateLimiter.sentCount;
await rateLimiter.getNextSendDelay();
expect(rateLimiter.sentCount).toBe(initialCount + 1);
});
test("should return pause duration when should pause", async () => {
// Force a pause condition
rateLimiter.sentCount = 49; // Next increment will trigger pause at 50
const delay = await rateLimiter.getNextSendDelay();
// Should be a long pause, not normal delay
expect(delay).toBeGreaterThan(30 * 60 * 1000); // More than 30 minutes
});
test("should return normal delay when not pausing", async () => {
rateLimiter.sentCount = 5; // Low count, won't trigger pause
const delay = await rateLimiter.getNextSendDelay();
// Should be normal delay (around 5 minutes with variance)
expect(delay).toBeGreaterThan(200000);
expect(delay).toBeLessThan(400000);
});
});
describe("formatDelay", () => {
test("should format milliseconds to readable time", () => {
expect(rateLimiter.formatDelay(60000)).toBe("1m 0s");
expect(rateLimiter.formatDelay(90000)).toBe("1m 30s");
expect(rateLimiter.formatDelay(125000)).toBe("2m 5s");
expect(rateLimiter.formatDelay(3661000)).toBe("61m 1s");
});
});
describe("getStats", () => {
test("should return correct statistics", () => {
rateLimiter.sentCount = 10;
rateLimiter.startTime = Date.now() - 60 * 60 * 1000; // 1 hour ago
const stats = rateLimiter.getStats();
expect(stats.sentCount).toBe(10);
expect(stats.runtime).toBe(60); // 60 minutes
expect(stats.averageRate).toBe("10.0"); // 10 emails per hour
expect(stats.nextDelay).toMatch(/^\d+m \d+s$/); // Format like "5m 23s"
});
test("should handle zero runtime gracefully", () => {
rateLimiter.sentCount = 5;
rateLimiter.startTime = Date.now(); // Just started
const stats = rateLimiter.getStats();
expect(stats.sentCount).toBe(5);
expect(stats.runtime).toBe(0);
// Average rate should be very high for instant runtime
expect(parseFloat(stats.averageRate)).toBeGreaterThan(1000);
});
});
describe("reset", () => {
test("should reset all counters", () => {
rateLimiter.sentCount = 10;
rateLimiter.lastSentTime = Date.now() - 60000;
rateLimiter.reset();
expect(rateLimiter.sentCount).toBe(0);
expect(rateLimiter.lastSentTime).toBeNull();
expect(rateLimiter.startTime).toBeCloseTo(Date.now(), -1); // Within 10ms
});
});
});
+148
View File
@@ -0,0 +1,148 @@
const { describe, test, expect, beforeEach } = require("@jest/globals");
const fs = require("fs").promises;
const path = require("path");
// Mock the config
jest.mock("../../config", () => ({
email: { user: "test@example.com" },
gif: { enabled: false, url: "", alt: "" },
}));
// Mock fs for template loading
jest.mock("fs", () => ({
promises: {
readFile: jest.fn(),
},
}));
const templateEngine = require("../../lib/templateEngine");
describe("TemplateEngine", () => {
beforeEach(() => {
jest.clearAllMocks();
// Clear template cache
templateEngine.templates = {};
});
describe("formatFirmData", () => {
test("should format firm data correctly", () => {
const firmData = {
firmName: "Test Law Firm",
location: "Test City",
website: "https://test.com",
contactEmail: "test@testfirm.com",
};
const result = templateEngine.formatFirmData(firmData);
expect(result).toEqual({
firmName: "Test Law Firm",
location: "Test City",
website: "https://test.com",
email: "test@testfirm.com",
greeting: "Legal Professional",
});
});
test("should handle missing data gracefully", () => {
const firmData = {
firmName: "Test Firm",
};
const result = templateEngine.formatFirmData(firmData);
expect(result).toEqual({
firmName: "Test Firm",
location: undefined,
website: undefined,
email: undefined,
greeting: "Legal Professional",
});
});
test("should use name as greeting when available", () => {
const firmData = {
firmName: "Test Firm",
name: "John Doe",
contactEmail: "john@test.com",
};
const result = templateEngine.formatFirmData(firmData);
expect(result.greeting).toBe("John Doe");
});
});
describe("loadTemplate", () => {
test("should load and compile templates", async () => {
const mockHtmlContent = "<h1>{{title}}</h1>";
const mockTxtContent = "{{title}}";
fs.readFile
.mockResolvedValueOnce(mockHtmlContent)
.mockResolvedValueOnce(mockTxtContent);
const result = await templateEngine.loadTemplate("test");
expect(fs.readFile).toHaveBeenCalledTimes(2);
expect(result).toHaveProperty("html");
expect(result).toHaveProperty("text");
expect(typeof result.html).toBe("function");
expect(typeof result.text).toBe("function");
});
test("should cache loaded templates", async () => {
const mockHtmlContent = "<h1>{{title}}</h1>";
const mockTxtContent = "{{title}}";
fs.readFile
.mockResolvedValueOnce(mockHtmlContent)
.mockResolvedValueOnce(mockTxtContent);
// Load template twice
await templateEngine.loadTemplate("test");
await templateEngine.loadTemplate("test");
// Should only read files once due to caching
expect(fs.readFile).toHaveBeenCalledTimes(2);
});
test("should throw error when template loading fails", async () => {
fs.readFile.mockRejectedValue(new Error("File not found"));
await expect(templateEngine.loadTemplate("nonexistent")).rejects.toThrow(
"Failed to load template nonexistent"
);
});
});
describe("render", () => {
test("should render template with data", async () => {
const mockHtmlContent = "<h1>Hello {{name}}</h1>";
const mockTxtContent = "Hello {{name}}";
fs.readFile
.mockResolvedValueOnce(mockHtmlContent)
.mockResolvedValueOnce(mockTxtContent);
const result = await templateEngine.render("test", { name: "World" });
expect(result.html).toContain("Hello World");
expect(result.text).toContain("Hello World");
});
test("should include default sender data", async () => {
const mockHtmlContent = "From: {{senderName}}";
const mockTxtContent = "From: {{senderName}}";
fs.readFile
.mockResolvedValueOnce(mockHtmlContent)
.mockResolvedValueOnce(mockTxtContent);
const result = await templateEngine.render("test", {});
expect(result.html).toContain("John Smith");
expect(result.text).toContain("John Smith");
});
});
});
+41
View File
@@ -0,0 +1,41 @@
// Jest setup file for global test configuration
const path = require("path");
// Set test environment
process.env.NODE_ENV = "test";
process.env.EMAIL_TEST_MODE = "true";
process.env.EMAIL_USER = "test@example.com";
process.env.EMAIL_PASS = "test-password";
process.env.DELAY_MINUTES = "0"; // No delay in tests
process.env.LOG_LEVEL = "error"; // Reduce log noise in tests
// Mock external dependencies
jest.mock("nodemailer", () => ({
createTransporter: jest.fn(() => ({
sendMail: jest.fn(() => Promise.resolve({ messageId: "test-123" })),
})),
}));
// Mock delay module to make tests faster
jest.mock("delay", () => jest.fn(() => Promise.resolve()));
// Console spy to reduce output noise in tests
global.consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {});
global.consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
global.consoleWarnSpy = jest
.spyOn(console, "warn")
.mockImplementation(() => {});
// Clean up after each test
afterEach(() => {
jest.clearAllMocks();
});
// Clean up after all tests
afterAll(() => {
global.consoleSpy.mockRestore();
global.consoleErrorSpy.mockRestore();
global.consoleWarnSpy.mockRestore();
});
+40
View File
@@ -0,0 +1,40 @@
{
"TestCampaigns": [
{
"firmName": "SaaS Test Firm",
"location": "Birmingham",
"website": "http://www.saastest.com",
"contactEmail": "test1@yourdomain.com",
"state": "Alabama",
"campaign": "campaign-1-saas",
"subject": "[TEST] LinkedIn Employment Intelligence API"
},
{
"firmName": "Data Service Test Firm",
"location": "Anchorage",
"website": "http://www.datatest.com",
"contactEmail": "test2@yourdomain.com",
"state": "Alaska",
"campaign": "campaign-2-data-service",
"subject": "[TEST] Monthly Employment Intelligence Reports"
},
{
"firmName": "License Test Firm",
"location": "Phoenix",
"website": "http://www.licensetest.com",
"contactEmail": "test3@yourdomain.com",
"state": "Arizona",
"campaign": "campaign-3-license",
"subject": "[TEST] Own Your LinkedIn Employment Parser"
},
{
"firmName": "GUI Test Firm",
"location": "Little Rock",
"website": "http://www.guitest.com",
"contactEmail": "test4@yourdomain.com",
"state": "Arkansas",
"campaign": "campaign-4-gui",
"subject": "[TEST] LinkedIn Employment Dashboard"
}
]
}