Initial commit: Complete Shopify Price Updater implementation
- Full Node.js application with Shopify GraphQL API integration - Compare At price support for promotional pricing - Comprehensive error handling and retry logic - Progress tracking with markdown logging - Complete test suite with unit and integration tests - Production-ready with proper exit codes and signal handling
This commit is contained in:
559
tests/services/progress.test.js
Normal file
559
tests/services/progress.test.js
Normal file
@@ -0,0 +1,559 @@
|
||||
const ProgressService = require("../../src/services/progress");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
|
||||
describe("ProgressService", () => {
|
||||
let progressService;
|
||||
let testFilePath;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use a unique test file for each test to avoid conflicts
|
||||
testFilePath = `test-progress-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}.md`;
|
||||
progressService = new ProgressService(testFilePath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test file after each test
|
||||
try {
|
||||
await fs.unlink(testFilePath);
|
||||
} catch (error) {
|
||||
// File might not exist, that's okay
|
||||
}
|
||||
});
|
||||
|
||||
describe("formatTimestamp", () => {
|
||||
test("should format timestamp correctly", () => {
|
||||
const testDate = new Date("2024-01-15T14:30:45.123Z");
|
||||
const formatted = progressService.formatTimestamp(testDate);
|
||||
|
||||
expect(formatted).toBe("2024-01-15 14:30:45 UTC");
|
||||
});
|
||||
|
||||
test("should use current date when no date provided", () => {
|
||||
const formatted = progressService.formatTimestamp();
|
||||
|
||||
// Should be a valid timestamp format
|
||||
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
||||
});
|
||||
|
||||
test("should handle different dates correctly", () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: new Date("2023-12-31T23:59:59.999Z"),
|
||||
expected: "2023-12-31 23:59:59 UTC",
|
||||
},
|
||||
{
|
||||
input: new Date("2024-01-01T00:00:00.000Z"),
|
||||
expected: "2024-01-01 00:00:00 UTC",
|
||||
},
|
||||
{
|
||||
input: new Date("2024-06-15T12:00:00.500Z"),
|
||||
expected: "2024-06-15 12:00:00 UTC",
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
expect(progressService.formatTimestamp(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("logOperationStart", () => {
|
||||
test("should create progress file and log operation start", async () => {
|
||||
const config = {
|
||||
targetTag: "test-tag",
|
||||
priceAdjustmentPercentage: 10,
|
||||
};
|
||||
|
||||
await progressService.logOperationStart(config);
|
||||
|
||||
// Check that file was created
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("# Shopify Price Update Progress Log");
|
||||
expect(content).toContain("## Price Update Operation -");
|
||||
expect(content).toContain("Target Tag: test-tag");
|
||||
expect(content).toContain("Price Adjustment: 10%");
|
||||
expect(content).toContain("**Configuration:**");
|
||||
expect(content).toContain("**Progress:**");
|
||||
});
|
||||
|
||||
test("should handle negative percentage", async () => {
|
||||
const config = {
|
||||
targetTag: "clearance",
|
||||
priceAdjustmentPercentage: -25,
|
||||
};
|
||||
|
||||
await progressService.logOperationStart(config);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Target Tag: clearance");
|
||||
expect(content).toContain("Price Adjustment: -25%");
|
||||
});
|
||||
|
||||
test("should handle special characters in tag", async () => {
|
||||
const config = {
|
||||
targetTag: "sale-2024_special!",
|
||||
priceAdjustmentPercentage: 15.5,
|
||||
};
|
||||
|
||||
await progressService.logOperationStart(config);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Target Tag: sale-2024_special!");
|
||||
expect(content).toContain("Price Adjustment: 15.5%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logProductUpdate", () => {
|
||||
test("should log successful product update", async () => {
|
||||
// First create the file
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123456789",
|
||||
productTitle: "Test Product",
|
||||
variantId: "gid://shopify/ProductVariant/987654321",
|
||||
oldPrice: 29.99,
|
||||
newPrice: 32.99,
|
||||
};
|
||||
|
||||
await progressService.logProductUpdate(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain(
|
||||
"✅ **Test Product** (gid://shopify/Product/123456789)"
|
||||
);
|
||||
expect(content).toContain(
|
||||
"Variant: gid://shopify/ProductVariant/987654321"
|
||||
);
|
||||
expect(content).toContain("Price: $29.99 → $32.99");
|
||||
expect(content).toContain("Updated:");
|
||||
});
|
||||
|
||||
test("should handle products with special characters in title", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: 'Product with "Quotes" & Special Chars!',
|
||||
variantId: "gid://shopify/ProductVariant/456",
|
||||
oldPrice: 10.0,
|
||||
newPrice: 11.0,
|
||||
};
|
||||
|
||||
await progressService.logProductUpdate(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain('**Product with "Quotes" & Special Chars!**');
|
||||
});
|
||||
|
||||
test("should handle decimal prices correctly", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 5.5,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Decimal Price Product",
|
||||
variantId: "gid://shopify/ProductVariant/456",
|
||||
oldPrice: 19.95,
|
||||
newPrice: 21.05,
|
||||
};
|
||||
|
||||
await progressService.logProductUpdate(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Price: $19.95 → $21.05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logError", () => {
|
||||
test("should log error with all details", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Failed Product",
|
||||
variantId: "gid://shopify/ProductVariant/456",
|
||||
errorMessage: "Invalid price data",
|
||||
};
|
||||
|
||||
await progressService.logError(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain(
|
||||
"❌ **Failed Product** (gid://shopify/Product/123)"
|
||||
);
|
||||
expect(content).toContain("Variant: gid://shopify/ProductVariant/456");
|
||||
expect(content).toContain("Error: Invalid price data");
|
||||
expect(content).toContain("Failed:");
|
||||
});
|
||||
|
||||
test("should handle error without variant ID", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Failed Product",
|
||||
errorMessage: "Product not found",
|
||||
};
|
||||
|
||||
await progressService.logError(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain(
|
||||
"❌ **Failed Product** (gid://shopify/Product/123)"
|
||||
);
|
||||
expect(content).not.toContain("Variant:");
|
||||
expect(content).toContain("Error: Product not found");
|
||||
});
|
||||
|
||||
test("should handle complex error messages", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Complex Error Product",
|
||||
variantId: "gid://shopify/ProductVariant/456",
|
||||
errorMessage:
|
||||
"GraphQL error: Field 'price' of type 'Money!' must not be null",
|
||||
};
|
||||
|
||||
await progressService.logError(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain(
|
||||
"Error: GraphQL error: Field 'price' of type 'Money!' must not be null"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logCompletionSummary", () => {
|
||||
test("should log completion summary with all statistics", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const startTime = new Date(Date.now() - 5000); // 5 seconds ago
|
||||
const summary = {
|
||||
totalProducts: 10,
|
||||
successfulUpdates: 8,
|
||||
failedUpdates: 2,
|
||||
startTime: startTime,
|
||||
};
|
||||
|
||||
await progressService.logCompletionSummary(summary);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("**Summary:**");
|
||||
expect(content).toContain("Total Products Processed: 10");
|
||||
expect(content).toContain("Successful Updates: 8");
|
||||
expect(content).toContain("Failed Updates: 2");
|
||||
expect(content).toContain("Duration: 5 seconds");
|
||||
expect(content).toContain("Completed:");
|
||||
expect(content).toContain("---");
|
||||
});
|
||||
|
||||
test("should handle summary without start time", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalProducts: 5,
|
||||
successfulUpdates: 5,
|
||||
failedUpdates: 0,
|
||||
};
|
||||
|
||||
await progressService.logCompletionSummary(summary);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Duration: Unknown seconds");
|
||||
});
|
||||
|
||||
test("should handle zero statistics", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalProducts: 0,
|
||||
successfulUpdates: 0,
|
||||
failedUpdates: 0,
|
||||
startTime: new Date(),
|
||||
};
|
||||
|
||||
await progressService.logCompletionSummary(summary);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Total Products Processed: 0");
|
||||
expect(content).toContain("Successful Updates: 0");
|
||||
expect(content).toContain("Failed Updates: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("categorizeError", () => {
|
||||
test("should categorize rate limiting errors", () => {
|
||||
const testCases = [
|
||||
"Rate limit exceeded",
|
||||
"HTTP 429 Too Many Requests",
|
||||
"Request was throttled",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Rate Limiting"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize network errors", () => {
|
||||
const testCases = [
|
||||
"Network connection failed",
|
||||
"Connection timeout",
|
||||
"Network error occurred",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Network Issues"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize authentication errors", () => {
|
||||
const testCases = [
|
||||
"Authentication failed",
|
||||
"HTTP 401 Unauthorized",
|
||||
"Invalid authentication credentials",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Authentication"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize permission errors", () => {
|
||||
const testCases = [
|
||||
"Permission denied",
|
||||
"HTTP 403 Forbidden",
|
||||
"Insufficient permissions",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Permissions"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize not found errors", () => {
|
||||
const testCases = [
|
||||
"Product not found",
|
||||
"HTTP 404 Not Found",
|
||||
"Resource not found",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Resource Not Found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize validation errors", () => {
|
||||
const testCases = [
|
||||
"Validation error: Invalid price",
|
||||
"Invalid product data",
|
||||
"Price validation failed",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Data Validation"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize server errors", () => {
|
||||
const testCases = [
|
||||
"Internal server error",
|
||||
"HTTP 500 Server Error",
|
||||
"HTTP 502 Bad Gateway",
|
||||
"HTTP 503 Service Unavailable",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Server Errors"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize Shopify API errors", () => {
|
||||
const testCases = [
|
||||
"Shopify API error occurred",
|
||||
"Shopify API request failed",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Shopify API"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize unknown errors as Other", () => {
|
||||
const testCases = [
|
||||
"Something went wrong",
|
||||
"Unexpected error",
|
||||
"Random failure message",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe("Other");
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle case insensitive categorization", () => {
|
||||
expect(progressService.categorizeError("RATE LIMIT EXCEEDED")).toBe(
|
||||
"Rate Limiting"
|
||||
);
|
||||
expect(progressService.categorizeError("Network Connection Failed")).toBe(
|
||||
"Network Issues"
|
||||
);
|
||||
expect(progressService.categorizeError("AUTHENTICATION FAILED")).toBe(
|
||||
"Authentication"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProgressEntry", () => {
|
||||
test("should create progress entry with timestamp", () => {
|
||||
const data = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Test Product",
|
||||
status: "success",
|
||||
};
|
||||
|
||||
const entry = progressService.createProgressEntry(data);
|
||||
|
||||
expect(entry).toHaveProperty("timestamp");
|
||||
expect(entry.timestamp).toBeInstanceOf(Date);
|
||||
expect(entry.productId).toBe("gid://shopify/Product/123");
|
||||
expect(entry.productTitle).toBe("Test Product");
|
||||
expect(entry.status).toBe("success");
|
||||
});
|
||||
|
||||
test("should preserve all original data", () => {
|
||||
const data = {
|
||||
productId: "gid://shopify/Product/456",
|
||||
productTitle: "Another Product",
|
||||
variantId: "gid://shopify/ProductVariant/789",
|
||||
oldPrice: 10.0,
|
||||
newPrice: 11.0,
|
||||
errorMessage: "Some error",
|
||||
};
|
||||
|
||||
const entry = progressService.createProgressEntry(data);
|
||||
|
||||
expect(entry.productId).toBe(data.productId);
|
||||
expect(entry.productTitle).toBe(data.productTitle);
|
||||
expect(entry.variantId).toBe(data.variantId);
|
||||
expect(entry.oldPrice).toBe(data.oldPrice);
|
||||
expect(entry.newPrice).toBe(data.newPrice);
|
||||
expect(entry.errorMessage).toBe(data.errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendToProgressFile", () => {
|
||||
test("should create file with header when file doesn't exist", async () => {
|
||||
await progressService.appendToProgressFile("Test content");
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("# Shopify Price Update Progress Log");
|
||||
expect(content).toContain(
|
||||
"This file tracks the progress of price update operations."
|
||||
);
|
||||
expect(content).toContain("Test content");
|
||||
});
|
||||
|
||||
test("should append to existing file without adding header", async () => {
|
||||
// Create file first
|
||||
await progressService.appendToProgressFile("First content");
|
||||
|
||||
// Append more content
|
||||
await progressService.appendToProgressFile("Second content");
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
// Should only have one header
|
||||
const headerCount = (
|
||||
content.match(/# Shopify Price Update Progress Log/g) || []
|
||||
).length;
|
||||
expect(headerCount).toBe(1);
|
||||
|
||||
expect(content).toContain("First content");
|
||||
expect(content).toContain("Second content");
|
||||
});
|
||||
|
||||
test("should handle file write errors gracefully", async () => {
|
||||
// Mock fs.appendFile to throw an error
|
||||
const originalAppendFile = fs.appendFile;
|
||||
const mockAppendFile = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Permission denied"));
|
||||
fs.appendFile = mockAppendFile;
|
||||
|
||||
// Should not throw an error, but should log warnings
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await progressService.appendToProgressFile("Test content");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Warning: Failed to write to progress file")
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
fs.appendFile = originalAppendFile;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user