import { vi } from "vitest";
import { CacheInvalidation } from "@/cache/CacheInvalidation.js";
import { HttpCacheWrapper } from "@/cache/HttpCacheWrapper.js";
// Mock HttpCacheWrapper
vi.mock("../../dist/cache/HttpCacheWrapper.js");
describe("CacheInvalidation", () => {
let invalidation;
let mockHttpCache;
beforeEach(() => {
mockHttpCache = {
invalidatePattern: vi.fn(),
invalidateKey: vi.fn(),
clear: vi.fn(),
getKeys: vi.fn().mockReturnValue([]),
siteId: "test-site",
};
HttpCacheWrapper.mockImplementation(() => mockHttpCache);
invalidation = new CacheInvalidation(mockHttpCache);
});
describe("constructor", () => {
it("should initialize with default rules", () => {
expect(invalidation).toBeDefined();
expect(invalidation.invalidationRules).toBeDefined();
expect(invalidation.eventQueue).toBeDefined();
});
it("should setup default invalidation rules", () => {
// Check that default rules are registered
expect(invalidation.invalidationRules.size).toBeGreaterThan(0);
// Verify some expected rules exist
expect(invalidation.invalidationRules.has("posts")).toBe(true);
expect(invalidation.invalidationRules.has("users")).toBe(true);
expect(invalidation.invalidationRules.has("comments")).toBe(true);
expect(invalidation.invalidationRules.has("media")).toBe(true);
});
});
describe("registerRule", () => {
it("should register new invalidation rule", () => {
const rule = {
trigger: "create",
patterns: ["custom/*"],
immediate: true,
cascade: false,
};
invalidation.registerRule("custom", rule);
const rules = invalidation.invalidationRules.get("custom");
expect(rules).toHaveLength(1);
expect(rules[0]).toEqual(rule);
});
it("should add multiple rules for same resource", () => {
const rule1 = {
trigger: "create",
patterns: ["custom/*"],
immediate: true,
};
const rule2 = {
trigger: "update",
patterns: ["custom/\\d+"],
immediate: false,
};
invalidation.registerRule("custom", rule1);
invalidation.registerRule("custom", rule2);
const rules = invalidation.invalidationRules.get("custom");
expect(rules).toHaveLength(2);
expect(rules[0]).toEqual(rule1);
expect(rules[1]).toEqual(rule2);
});
it("should handle different trigger types", () => {
const createRule = { trigger: "create", patterns: ["*"] };
const updateRule = { trigger: "update", patterns: ["*"] };
const deleteRule = { trigger: "delete", patterns: ["*"] };
invalidation.registerRule("test", createRule);
invalidation.registerRule("test", updateRule);
invalidation.registerRule("test", deleteRule);
const rules = invalidation.invalidationRules.get("test");
expect(rules).toHaveLength(3);
expect(rules.map((r) => r.trigger)).toEqual(["create", "update", "delete"]);
});
});
describe("trigger", () => {
it("should add event to queue", async () => {
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
await invalidation.trigger(event);
expect(invalidation.eventQueue).toContainEqual(event);
});
it("should process queue immediately if not processing", async () => {
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
const processQueueSpy = vi.spyOn(invalidation, "processQueue");
await invalidation.trigger(event);
expect(processQueueSpy).toHaveBeenCalled();
});
it("should not process queue if already processing", async () => {
invalidation.processing = true;
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
const processQueueSpy = vi.spyOn(invalidation, "processQueue");
await invalidation.trigger(event);
expect(processQueueSpy).not.toHaveBeenCalled();
expect(invalidation.eventQueue).toContainEqual(event);
});
});
describe("invalidateResource", () => {
it("should create and trigger invalidation event", async () => {
const triggerSpy = vi.spyOn(invalidation, "trigger");
await invalidation.invalidateResource("posts", 123, "update");
expect(triggerSpy).toHaveBeenCalledWith({
type: "update",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: expect.any(Number),
});
});
it("should use default type when not specified", async () => {
const triggerSpy = vi.spyOn(invalidation, "trigger");
await invalidation.invalidateResource("posts", 123);
expect(triggerSpy).toHaveBeenCalledWith({
type: "update",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: expect.any(Number),
});
});
it("should handle resource without ID", async () => {
const triggerSpy = vi.spyOn(invalidation, "trigger");
await invalidation.invalidateResource("posts", undefined, "create");
expect(triggerSpy).toHaveBeenCalledWith({
type: "create",
resource: "posts",
id: undefined,
siteId: "test-site",
timestamp: expect.any(Number),
});
});
});
describe("processQueue", () => {
it("should process events in queue", async () => {
const event1 = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
const event2 = {
type: "update",
resource: "posts",
id: 124,
siteId: "test-site",
timestamp: Date.now(),
};
invalidation.eventQueue = [event1, event2];
const processEventSpy = vi.spyOn(invalidation, "processEvent");
await invalidation.processQueue();
expect(processEventSpy).toHaveBeenCalledWith(event1);
expect(processEventSpy).toHaveBeenCalledWith(event2);
expect(invalidation.eventQueue).toHaveLength(0);
});
it("should set processing flag during queue processing", async () => {
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
invalidation.eventQueue = [event];
let processingDuringExecution = false;
vi.spyOn(invalidation, "processEvent").mockImplementation(async () => {
processingDuringExecution = invalidation.processing;
return Promise.resolve();
});
await invalidation.processQueue();
expect(processingDuringExecution).toBe(true);
expect(invalidation.processing).toBe(false);
});
it("should handle errors during processing", async () => {
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
invalidation.eventQueue = [event];
vi.spyOn(invalidation, "processEvent").mockRejectedValue(new Error("Process error"));
await expect(invalidation.processQueue()).resolves.not.toThrow();
expect(invalidation.processing).toBe(false);
});
});
describe("processEvent", () => {
it("should apply matching rules for event", async () => {
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
const applyRuleSpy = vi.spyOn(invalidation, "applyRule");
await invalidation.processEvent(event);
expect(applyRuleSpy).toHaveBeenCalled();
});
it("should skip rules that don't match trigger", async () => {
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
// Add a rule that doesn't match
invalidation.registerRule("posts", {
trigger: "delete",
patterns: ["posts/*"],
});
const applyRuleSpy = vi.spyOn(invalidation, "applyRule");
await invalidation.processEvent(event);
// Should not call applyRule for mismatched trigger
expect(applyRuleSpy).not.toHaveBeenCalledWith(expect.objectContaining({ trigger: "delete" }), event);
});
it("should handle event with no matching rules", async () => {
const event = {
type: "create",
resource: "unknown",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
await expect(invalidation.processEvent(event)).resolves.not.toThrow();
});
});
describe("applyRule", () => {
it("should invalidate cache patterns from rule", async () => {
const rule = {
trigger: "create",
patterns: ["posts/*", "posts/\\d+"],
immediate: true,
};
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
await invalidation.applyRule(rule, event);
expect(mockHttpCache.invalidatePattern).toHaveBeenCalledWith("posts/*");
expect(mockHttpCache.invalidatePattern).toHaveBeenCalledWith("posts/\\d+");
});
it("should handle immediate invalidation", async () => {
const rule = {
trigger: "create",
patterns: ["posts/*"],
immediate: true,
};
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
await invalidation.applyRule(rule, event);
expect(mockHttpCache.invalidatePattern).toHaveBeenCalledWith("posts/*");
});
it("should handle cascading invalidation", async () => {
const rule = {
trigger: "create",
patterns: ["posts/*"],
cascade: true,
};
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
// Mock related patterns
mockHttpCache.getKeys.mockReturnValue(["posts/123", "categories/1", "tags/2"]);
await invalidation.applyRule(rule, event);
expect(mockHttpCache.invalidatePattern).toHaveBeenCalledWith("posts/*");
});
it("should substitute patterns with event data", async () => {
const rule = {
trigger: "update",
patterns: ["posts/{id}", "categories/{category}"],
immediate: true,
};
const event = {
type: "update",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
data: { category: 5 },
};
await invalidation.applyRule(rule, event);
expect(mockHttpCache.invalidatePattern).toHaveBeenCalledWith("posts/123");
expect(mockHttpCache.invalidatePattern).toHaveBeenCalledWith("categories/5");
});
});
describe("default rules", () => {
it("should have post invalidation rules", () => {
const postRules = invalidation.invalidationRules.get("posts");
expect(postRules).toBeDefined();
expect(postRules.length).toBeGreaterThan(0);
const createRule = postRules.find((r) => r.trigger === "create");
expect(createRule).toBeDefined();
expect(createRule.patterns).toContain("posts");
expect(createRule.immediate).toBe(true);
expect(createRule.cascade).toBe(true);
});
it("should have user invalidation rules", () => {
const userRules = invalidation.invalidationRules.get("users");
expect(userRules).toBeDefined();
expect(userRules.length).toBeGreaterThan(0);
const updateRule = userRules.find((r) => r.trigger === "update");
expect(updateRule).toBeDefined();
expect(updateRule.patterns).toContain("users/\\d+");
});
it("should have comment invalidation rules", () => {
const commentRules = invalidation.invalidationRules.get("comments");
expect(commentRules).toBeDefined();
expect(commentRules.length).toBeGreaterThan(0);
const createRule = commentRules.find((r) => r.trigger === "create");
expect(createRule).toBeDefined();
expect(createRule.patterns).toContain("comments");
});
it("should have media invalidation rules", () => {
const mediaRules = invalidation.invalidationRules.get("media");
expect(mediaRules).toBeDefined();
expect(mediaRules.length).toBeGreaterThan(0);
const deleteRule = mediaRules.find((r) => r.trigger === "delete");
expect(deleteRule).toBeDefined();
expect(deleteRule.patterns).toContain("media");
});
});
describe("pattern matching", () => {
it("should match simple patterns", () => {
expect(invalidation.matchPattern("posts", "posts")).toBe(true);
expect(invalidation.matchPattern("posts/123", "posts/*")).toBe(true);
expect(invalidation.matchPattern("posts/123", "posts/\\d+")).toBe(true);
expect(invalidation.matchPattern("posts/abc", "posts/\\d+")).toBe(false);
});
it("should match regex patterns", () => {
expect(invalidation.matchPattern("posts/123", "posts/\\d+")).toBe(true);
expect(invalidation.matchPattern("posts/123/comments", "posts/\\d+/comments")).toBe(true);
expect(invalidation.matchPattern("posts/abc", "posts/\\d+")).toBe(false);
});
it("should handle wildcard patterns", () => {
expect(invalidation.matchPattern("posts/123", "posts/*")).toBe(true);
expect(invalidation.matchPattern("posts/123/comments", "posts/*")).toBe(true);
expect(invalidation.matchPattern("users/123", "posts/*")).toBe(false);
});
it("should handle complex patterns", () => {
expect(invalidation.matchPattern("posts/123?page=2", "posts/\\d+.*")).toBe(true);
expect(invalidation.matchPattern("posts/123/comments?filter=approved", "posts/\\d+/comments.*")).toBe(true);
});
});
describe("batch invalidation", () => {
it("should process multiple events in batch", async () => {
const events = [
{
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
},
{
type: "update",
resource: "posts",
id: 124,
siteId: "test-site",
timestamp: Date.now(),
},
];
await invalidation.batchInvalidate(events);
expect(mockHttpCache.invalidatePattern).toHaveBeenCalledTimes(6); // 3 patterns per event
});
it("should deduplicate patterns in batch", async () => {
const events = [
{
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
},
{
type: "create",
resource: "posts",
id: 124,
siteId: "test-site",
timestamp: Date.now(),
},
];
await invalidation.batchInvalidate(events);
// Should not duplicate pattern invalidations
const callCount = mockHttpCache.invalidatePattern.mock.calls.length;
const uniquePatterns = new Set(mockHttpCache.invalidatePattern.mock.calls.map((call) => call[0]));
expect(uniquePatterns.size).toBeLessThanOrEqual(callCount);
});
});
describe("error handling", () => {
it("should handle invalidation errors gracefully", async () => {
mockHttpCache.invalidatePattern.mockRejectedValue(new Error("Cache error"));
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
await expect(invalidation.processEvent(event)).resolves.not.toThrow();
});
it("should handle malformed events", async () => {
const malformedEvent = {
type: "invalid",
resource: null,
siteId: "test-site",
timestamp: Date.now(),
};
await expect(invalidation.processEvent(malformedEvent)).resolves.not.toThrow();
});
it("should handle empty rule patterns", async () => {
const rule = {
trigger: "create",
patterns: [],
immediate: true,
};
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
await expect(invalidation.applyRule(rule, event)).resolves.not.toThrow();
expect(mockHttpCache.invalidatePattern).not.toHaveBeenCalled();
});
});
describe("performance", () => {
it("should handle high volume events efficiently", async () => {
const events = [];
for (let i = 0; i < 1000; i++) {
events.push({
type: "create",
resource: "posts",
id: i,
siteId: "test-site",
timestamp: Date.now(),
});
}
const startTime = Date.now();
for (const event of events) {
await invalidation.trigger(event);
}
const endTime = Date.now();
const duration = endTime - startTime;
// Should process 1000 events in reasonable time (< 1 second)
expect(duration).toBeLessThan(1000);
});
it("should not block on queue processing", async () => {
const event = {
type: "create",
resource: "posts",
id: 123,
siteId: "test-site",
timestamp: Date.now(),
};
// Mock slow processing
vi.spyOn(invalidation, "processEvent").mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
const startTime = Date.now();
// Trigger should return immediately
await invalidation.trigger(event);
const endTime = Date.now();
const duration = endTime - startTime;
// Should not wait for processing to complete
expect(duration).toBeLessThan(50);
});
});
});