/**
* Memory Deletion Integration Tests (Mocked)
*
* Tests the interaction between MemoryRepository, WaypointBuilder, and EmbeddingStorage
* for cascade deletion operations using mocks for external dependencies.
*
* This is an integration test that verifies internal module interactions work correctly,
* NOT a test of external service integration.
*
* Requirements: 12.2, 12. 12.6
*/
import type { PoolClient } from "pg";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Create mock database client
function createMockClient(): PoolClient {
const mockClient = {
query: vi.fn(),
release: vi.fn(),
} as unknown as PoolClient;
return mockClient;
}
// Create mock database manager
function createMockDbManager() {
const mockClient = createMockClient();
return {
client: mockClient,
manager: {
getConnection: vi.fn().mockResolvedValue(mockClient),
releaseConnection: vi.fn(),
beginTransaction: vi.fn().mockResolvedValue(mockClient),
commitTransaction: vi.fn().mockResolvedValue(undefined),
rollbackTransaction: vi.fn().mockResolvedValue(undefined),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
},
};
}
// Create mock embedding storage
function createMockEmbeddingStorage() {
return {
storeEmbeddings: vi.fn().mockResolvedValue(undefined),
retrieveEmbeddings: vi.fn().mockResolvedValue({
episodic: new Array(1536).fill(0.1),
semantic: new Array(1536).fill(0.1),
procedural: new Array(1536).fill(0.1),
emotional: new Array(1536).fill(0.1),
reflective: new Array(1536).fill(0.1),
}),
vectorSimilaritySearch: vi.fn().mockResolvedValue([]),
deleteEmbeddings: vi.fn().mockResolvedValue(undefined),
};
}
// Create mock waypoint graph builder
function createMockGraphBuilder() {
return {
createWaypointLinks: vi.fn().mockResolvedValue({
links: [],
processingTime: 10,
}),
deleteLinksForMemory: vi.fn().mockResolvedValue(undefined),
};
}
describe("Memory Deletion Integration (Mocked)", () => {
let mockDb: ReturnType<typeof createMockDbManager>;
let mockEmbeddingStorage: ReturnType<typeof createMockEmbeddingStorage>;
let mockGraphBuilder: ReturnType<typeof createMockGraphBuilder>;
beforeEach(() => {
vi.clearAllMocks();
mockDb = createMockDbManager();
mockEmbeddingStorage = createMockEmbeddingStorage();
mockGraphBuilder = createMockGraphBuilder();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Cascade Deletion of Embeddings", () => {
it("should cascade delete all embeddings when memory is hard deleted", async () => {
const mockMemoryId = "test-memory-cascade-embeddings";
// Setup mock to find the memory
(mockDb.client.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId }], rowCount: 1 }) // Memory exists
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // Delete links
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // Delete metadata
.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // Delete memory
const client = await mockDb.manager.beginTransaction();
// Step 1: Verify memory exists
const memoryCheck = await client.query("SELECT id FROM memories WHERE id = $1", [
mockMemoryId,
]);
expect(memoryCheck.rows.length).toBe(1);
// Step 2: Delete embeddings
await mockEmbeddingStorage.deleteEmbeddings(mockMemoryId);
expect(mockEmbeddingStorage.deleteEmbeddings).toHaveBeenCalledWith(mockMemoryId);
// Step 3: Delete links
await mockGraphBuilder.deleteLinksForMemory(mockMemoryId);
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledWith(mockMemoryId);
// Step 4: Delete memory record
await client.query("DELETE FROM memories WHERE id = $1", [mockMemoryId]);
// Step 5: Commit transaction
await mockDb.manager.commitTransaction(client);
expect(mockDb.manager.commitTransaction).toHaveBeenCalledWith(client);
});
it("should delete embeddings for all five sectors", async () => {
const mockMemoryId = "test-memory-five-sectors";
// Mock retrieveEmbeddings to return all five sectors
mockEmbeddingStorage.retrieveEmbeddings.mockResolvedValue({
episodic: new Array(1536).fill(0.1),
semantic: new Array(1536).fill(0.2),
procedural: new Array(1536).fill(0.3),
emotional: new Array(1536).fill(0.4),
reflective: new Array(1536).fill(0.5),
});
// Verify embeddings exist before deletion
const embeddings = await mockEmbeddingStorage.retrieveEmbeddings(mockMemoryId);
expect(embeddings).toHaveProperty("episodic");
expect(embeddings).toHaveProperty("semantic");
expect(embeddings).toHaveProperty("procedural");
expect(embeddings).toHaveProperty("emotional");
expect(embeddings).toHaveProperty("reflective");
// Delete embeddings
await mockEmbeddingStorage.deleteEmbeddings(mockMemoryId);
expect(mockEmbeddingStorage.deleteEmbeddings).toHaveBeenCalledWith(mockMemoryId);
});
});
describe("Cascade Deletion of Connections", () => {
it("should cascade delete all waypoint connections when memory is deleted", async () => {
const mockMemoryId = "test-memory-cascade-connections";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId }], rowCount: 1 }) // Memory exists
.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // Delete memory
const client = await mockDb.manager.beginTransaction();
// Verify memory exists
const memoryCheck = await client.query("SELECT id FROM memories WHERE id = $1", [
mockMemoryId,
]);
expect(memoryCheck.rows.length).toBe(1);
// Delete waypoint links
await mockGraphBuilder.deleteLinksForMemory(mockMemoryId);
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledWith(mockMemoryId);
// Commit transaction
await mockDb.manager.commitTransaction(client);
});
it("should delete bidirectional connections", async () => {
const memoryId1 = "test-memory-bidirectional-1";
// memoryId2 represents the other end of bidirectional link (not used directly in test)
// When deleting memory1, both directions of links should be removed
await mockGraphBuilder.deleteLinksForMemory(memoryId1);
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledWith(memoryId1);
// The graph builder should handle both source_id and target_id cleanup
// This is verified by the mock being called with the memory ID
});
it("should not delete connections between other memories", async () => {
const memoryIdToDelete = "test-memory-to-delete";
const memoryIdToKeep1 = "test-memory-keep-1";
const memoryIdToKeep2 = "test-memory-keep-2";
// Delete only the specified memory's links
await mockGraphBuilder.deleteLinksForMemory(memoryIdToDelete);
// Verify only the deleted memory's links were targeted
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledWith(memoryIdToDelete);
expect(mockGraphBuilder.deleteLinksForMemory).not.toHaveBeenCalledWith(memoryIdToKeep1);
expect(mockGraphBuilder.deleteLinksForMemory).not.toHaveBeenCalledWith(memoryIdToKeep2);
});
});
describe("Cascade Deletion of Metadata", () => {
it("should cascade delete metadata when memory is deleted", async () => {
const mockMemoryId = "test-memory-cascade-metadata";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId }], rowCount: 1 }) // Memory exists
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // Delete metadata
.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // Delete memory
const client = await mockDb.manager.beginTransaction();
// Verify memory exists
await client.query("SELECT id FROM memories WHERE id = $1", [mockMemoryId]);
// Delete metadata
await client.query("DELETE FROM memory_metadata WHERE memory_id = $1", [mockMemoryId]);
expect(mockDb.client.query).toHaveBeenCalled();
// Commit transaction
await mockDb.manager.commitTransaction(client);
});
});
describe("Soft Delete vs Hard Delete", () => {
it("should perform soft delete by setting strength to 0", async () => {
const mockMemoryId = "test-memory-soft-delete";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId, strength: 1.0 }], rowCount: 1 }) // Memory exists
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId, strength: 0 }], rowCount: 1 }); // After update
const client = await mockDb.manager.beginTransaction();
// Soft delete sets strength to 0
await client.query("UPDATE memories SET strength = 0 WHERE id = $1", [mockMemoryId]);
expect(mockDb.client.query).toHaveBeenCalled();
// Commit transaction
await mockDb.manager.commitTransaction(client);
expect(mockDb.manager.commitTransaction).toHaveBeenCalled();
});
it("should not cascade delete on soft delete", async () => {
const mockMemoryId = "test-memory-soft-no-cascade";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>).mockResolvedValue({
rows: [{ id: mockMemoryId }],
rowCount: 1,
});
const client = await mockDb.manager.beginTransaction();
// Soft delete only updates strength
await client.query("UPDATE memories SET strength = 0 WHERE id = $1", [mockMemoryId]);
// Embeddings should NOT be deleted for soft delete
expect(mockEmbeddingStorage.deleteEmbeddings).not.toHaveBeenCalled();
// Links should NOT be deleted for soft delete
expect(mockGraphBuilder.deleteLinksForMemory).not.toHaveBeenCalled();
await mockDb.manager.commitTransaction(client);
});
it("should perform hard delete by removing record completely", async () => {
const mockMemoryId = "test-memory-hard-delete";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId }], rowCount: 1 }) // Memory exists
.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // Delete memory
const client = await mockDb.manager.beginTransaction();
// Hard delete removes the record
await mockEmbeddingStorage.deleteEmbeddings(mockMemoryId);
await mockGraphBuilder.deleteLinksForMemory(mockMemoryId);
await client.query("DELETE FROM memories WHERE id = $1", [mockMemoryId]);
expect(mockEmbeddingStorage.deleteEmbeddings).toHaveBeenCalledWith(mockMemoryId);
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledWith(mockMemoryId);
await mockDb.manager.commitTransaction(client);
});
it("should allow hard delete after soft delete", async () => {
const mockMemoryId = "test-memory-soft-then-hard";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId, strength: 0 }], rowCount: 1 }) // Soft deleted memory
.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // Hard delete
const client = await mockDb.manager.beginTransaction();
// Verify soft-deleted memory exists (strength = 0)
const softDeleted = await client.query("SELECT * FROM memories WHERE id = $1", [
mockMemoryId,
]);
expect(softDeleted.rows[0].strength).toBe(0);
// Hard delete the soft-deleted memory
await mockEmbeddingStorage.deleteEmbeddings(mockMemoryId);
await mockGraphBuilder.deleteLinksForMemory(mockMemoryId);
await client.query("DELETE FROM memories WHERE id = $1", [mockMemoryId]);
await mockDb.manager.commitTransaction(client);
});
});
describe("Error Handling", () => {
it("should throw error when deleting non-existent memory", async () => {
const nonExistentId = "non-existent-memory";
// Setup mock to return empty result
(mockDb.client.query as ReturnType<typeof vi.fn>).mockResolvedValue({
rows: [],
rowCount: 0,
});
const client = await mockDb.manager.beginTransaction();
// Check if memory exists
const result = await client.query("SELECT id FROM memories WHERE id = $1", [nonExistentId]);
expect(result.rows.length).toBe(0);
// In real implementation, this would throw MemoryValidationError
});
it("should handle deletion of memory with no connections gracefully", async () => {
const mockMemoryId = "test-memory-no-connections";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId }], rowCount: 1 }) // Memory exists
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // No links to delete
.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // Delete memory
const client = await mockDb.manager.beginTransaction();
// Delete links (none exist)
await mockGraphBuilder.deleteLinksForMemory(mockMemoryId);
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledWith(mockMemoryId);
// Delete memory should still succeed
await client.query("DELETE FROM memories WHERE id = $1", [mockMemoryId]);
await mockDb.manager.commitTransaction(client);
});
it("should rollback transaction on deletion failure", async () => {
const mockMemoryId = "test-memory-rollback";
// Setup mock to fail during deletion
mockEmbeddingStorage.deleteEmbeddings.mockRejectedValue(
new Error("Embedding deletion failed")
);
const client = await mockDb.manager.beginTransaction();
// Attempt deletion (should fail)
await expect(mockEmbeddingStorage.deleteEmbeddings(mockMemoryId)).rejects.toThrow(
"Embedding deletion failed"
);
// Rollback should be called
await mockDb.manager.rollbackTransaction(client);
expect(mockDb.manager.rollbackTransaction).toHaveBeenCalledWith(client);
});
it("should handle empty memoryId validation", async () => {
const emptyId = "";
// In real implementation, this would throw MemoryValidationError
// Here we verify the validation logic would be triggered
expect(emptyId.trim()).toBe("");
});
});
describe("Complete Cascade Deletion", () => {
it("should cascade delete all related data in single operation", async () => {
const mockMemoryId = "test-memory-complete-cascade";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ rows: [{ id: mockMemoryId }], rowCount: 1 }) // Memory exists
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // Delete metadata
.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // Delete memory
const client = await mockDb.manager.beginTransaction();
// Step 1: Delete embeddings
await mockEmbeddingStorage.deleteEmbeddings(mockMemoryId);
expect(mockEmbeddingStorage.deleteEmbeddings).toHaveBeenCalledWith(mockMemoryId);
// Step 2: Delete waypoint links
await mockGraphBuilder.deleteLinksForMemory(mockMemoryId);
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledWith(mockMemoryId);
// Step 3: Delete metadata
await client.query("DELETE FROM memory_metadata WHERE memory_id = $1", [mockMemoryId]);
// Step 4: Delete memory
await client.query("DELETE FROM memories WHERE id = $1", [mockMemoryId]);
// Step 5: Commit
await mockDb.manager.commitTransaction(client);
expect(mockDb.manager.commitTransaction).toHaveBeenCalled();
});
});
describe("Batch Deletion", () => {
it("should batch hard delete multiple memories", async () => {
const memoryIds = ["batch-memory-1", "batch-memory-2", "batch-memory-3"];
// Setup mock responses for each memory
(mockDb.client.query as ReturnType<typeof vi.fn>).mockResolvedValue({
rows: memoryIds.map((id) => ({ id })),
rowCount: memoryIds.length,
});
const client = await mockDb.manager.beginTransaction();
const results = {
successCount: 0,
failureCount: 0,
failures: [] as Array<{ memoryId: string; error: string }>,
};
// Process each memory
for (const memoryId of memoryIds) {
await mockEmbeddingStorage.deleteEmbeddings(memoryId);
await mockGraphBuilder.deleteLinksForMemory(memoryId);
await client.query("DELETE FROM memories WHERE id = $1", [memoryId]);
results.successCount++;
}
await mockDb.manager.commitTransaction(client);
expect(results.successCount).toBe(3);
expect(results.failureCount).toBe(0);
expect(mockEmbeddingStorage.deleteEmbeddings).toHaveBeenCalledTimes(3);
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledTimes(3);
});
it("should batch soft delete multiple memories", async () => {
const memoryIds = ["soft-batch-1", "soft-batch-2", "soft-batch-3"];
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>).mockResolvedValue({
rows: memoryIds.map((id) => ({ id, strength: 0 })),
rowCount: memoryIds.length,
});
const client = await mockDb.manager.beginTransaction();
// Soft delete all memories
for (const memoryId of memoryIds) {
await client.query("UPDATE memories SET strength = 0 WHERE id = $1", [memoryId]);
}
await mockDb.manager.commitTransaction(client);
// Embeddings and links should NOT be deleted for soft delete
expect(mockEmbeddingStorage.deleteEmbeddings).not.toHaveBeenCalled();
expect(mockGraphBuilder.deleteLinksForMemory).not.toHaveBeenCalled();
});
it("should handle batch delete with mixed results", async () => {
const validIds = ["valid-1", "valid-2"];
const invalidIds = ["invalid-1", "invalid-2"];
const allIds = [...validIds, ...invalidIds];
// Setup mock to return only valid memories
(mockDb.client.query as ReturnType<typeof vi.fn>).mockImplementation(
async (_sql: string, params?: unknown[]) => {
const id = params?.[0] as string;
if (validIds.includes(id)) {
return { rows: [{ id }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
);
const client = await mockDb.manager.beginTransaction();
const results = {
successCount: 0,
failureCount: 0,
failures: [] as Array<{ memoryId: string; error: string }>,
};
for (const memoryId of allIds) {
const checkResult = await client.query("SELECT id FROM memories WHERE id = $1", [memoryId]);
if (checkResult.rows.length === 0) {
results.failureCount++;
results.failures.push({ memoryId, error: "Memory not found" });
} else {
await mockEmbeddingStorage.deleteEmbeddings(memoryId);
await mockGraphBuilder.deleteLinksForMemory(memoryId);
results.successCount++;
}
}
await mockDb.manager.commitTransaction(client);
expect(results.successCount).toBe(2);
expect(results.failureCount).toBe(2);
expect(results.failures.map((f) => f.memoryId)).toEqual(invalidIds);
});
it("should throw error for empty array", async () => {
const emptyIds: string[] = [];
// In real implementation, this would throw MemoryValidationError
expect(emptyIds.length).toBe(0);
});
it("should handle batch delete with single memory", async () => {
const memoryId = "single-batch-memory";
// Setup mock responses
(mockDb.client.query as ReturnType<typeof vi.fn>).mockResolvedValue({
rows: [{ id: memoryId }],
rowCount: 1,
});
const client = await mockDb.manager.beginTransaction();
await mockEmbeddingStorage.deleteEmbeddings(memoryId);
await mockGraphBuilder.deleteLinksForMemory(memoryId);
await client.query("DELETE FROM memories WHERE id = $1", [memoryId]);
await mockDb.manager.commitTransaction(client);
expect(mockEmbeddingStorage.deleteEmbeddings).toHaveBeenCalledWith(memoryId);
expect(mockGraphBuilder.deleteLinksForMemory).toHaveBeenCalledWith(memoryId);
});
});
describe("Transaction Error Handling", () => {
it("should wrap non-validation errors in transaction error", async () => {
// Test transaction error handling (memoryId context not needed for this test)
// Setup mock to fail on begin transaction
mockDb.manager.beginTransaction.mockRejectedValue(new Error("Database connection failed"));
await expect(mockDb.manager.beginTransaction()).rejects.toThrow("Database connection failed");
});
it("should rollback transaction on error", async () => {
const mockMemoryId = "test-rollback-error";
// Setup mock to fail during query
(mockDb.client.query as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Query execution failed")
);
const client = await mockDb.manager.beginTransaction();
// Attempt query (should fail)
await expect(
client.query("DELETE FROM memories WHERE id = $1", [mockMemoryId])
).rejects.toThrow("Query execution failed");
// Rollback should be called
await mockDb.manager.rollbackTransaction(client);
expect(mockDb.manager.rollbackTransaction).toHaveBeenCalledWith(client);
});
});
describe("Batch Delete Error Handling", () => {
it("should handle batch delete transaction rollback", async () => {
const memoryIds = ["batch-rollback-1", "batch-rollback-2"];
// Setup mock to fail during deletion
mockEmbeddingStorage.deleteEmbeddings.mockRejectedValue(new Error("Batch deletion failed"));
const client = await mockDb.manager.beginTransaction();
// Attempt batch deletion (should fail)
await expect(mockEmbeddingStorage.deleteEmbeddings(memoryIds[0])).rejects.toThrow(
"Batch deletion failed"
);
// Rollback should be called
await mockDb.manager.rollbackTransaction(client);
expect(mockDb.manager.rollbackTransaction).toHaveBeenCalledWith(client);
});
});
});