UNPKG

@aj-archipelago/cortex

Version:

Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.

622 lines (519 loc) 21.7 kB
import test from "ava"; import axios from "axios"; import FormData from "form-data"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { v4 as uuidv4 } from "uuid"; import { port } from "../src/start.js"; import { cleanupHashAndFile, createTestMediaFile, startTestServer, stopTestServer, setupTestDirectory } from "./testUtils.helper.js"; import { setFileStoreMap, getFileStoreMap, removeFromFileStoreMap } from "../src/redis.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const baseUrl = `http://localhost:${port}/api/CortexFileHandler`; // Helper function to create test files async function createTestFile(content, extension) { const testDir = path.join(__dirname, "test-files"); if (!fs.existsSync(testDir)) { fs.mkdirSync(testDir, { recursive: true }); } const filename = path.join( testDir, `test-delete-${uuidv4().slice(0, 8)}.${extension}`, ); fs.writeFileSync(filename, content); return filename; } // Helper function to upload file async function uploadFile(filePath, requestId = null, hash = null) { const form = new FormData(); form.append("file", fs.createReadStream(filePath)); if (requestId) form.append("requestId", requestId); if (hash) form.append("hash", hash); return await axios.post(baseUrl, form, { headers: form.getHeaders(), validateStatus: (status) => true, timeout: 15000, }); } // Helper function to delete file by hash async function deleteFileByHash(hash) { return await axios.delete(`${baseUrl}?hash=${hash}`, { validateStatus: (status) => true, timeout: 10000, }); } // Helper function to check if hash exists async function checkHashExists(hash) { return await axios.get(`${baseUrl}?hash=${hash}&checkHash=true`, { validateStatus: (status) => true, timeout: 10000, }); } // Test setup test.before(async (t) => { await setupTestDirectory(__dirname); await startTestServer(); }); test.after(async (t) => { await stopTestServer(); }); // Basic delete by hash tests test.serial("should delete file by hash successfully", async (t) => { const testContent = "test content for hash deletion"; const testHash = `test-hash-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with hash uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); t.truthy(uploadResponse.data.url, "Should have file URL"); // Verify file exists via hash const hashCheckBefore = await checkHashExists(testHash); t.is(hashCheckBefore.status, 200, "Hash should exist before deletion"); // Delete file by hash const deleteResponse = await deleteFileByHash(testHash); t.is(deleteResponse.status, 200, "Delete should succeed"); t.truthy(deleteResponse.data.message, "Should have success message"); t.true(deleteResponse.data.message.includes(testHash), "Message should include hash"); t.truthy(deleteResponse.data.deleted, "Should have deletion details"); // Verify file is gone via hash const hashCheckAfter = await checkHashExists(testHash); t.is(hashCheckAfter.status, 404, "Hash should not exist after deletion"); // Wait a moment for deletion to propagate await new Promise(resolve => setTimeout(resolve, 1000)); // Verify file URL is no longer accessible const fileResponse = await axios.get(uploadResponse.data.url, { validateStatus: (status) => true, }); t.is(fileResponse.status, 404, "File URL should return 404 after deletion"); } finally { fs.unlinkSync(filePath); // Additional cleanup just in case try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should return 404 when deleting non-existent hash", async (t) => { const nonExistentHash = `non-existent-${uuidv4()}`; const deleteResponse = await deleteFileByHash(nonExistentHash); t.is(deleteResponse.status, 404, "Should return 404 for non-existent hash"); t.truthy(deleteResponse.data, "Should have error message"); t.true(deleteResponse.data.includes("not found"), "Error should indicate file not found"); }); test.serial("should return 400 when hash parameter is missing", async (t) => { const deleteResponse = await axios.delete(baseUrl, { validateStatus: (status) => true, timeout: 10000, }); t.is(deleteResponse.status, 400, "Should return 400 for missing parameters"); t.truthy(deleteResponse.data, "Should have error response"); }); test.serial("should delete file with both primary and backup storage", async (t) => { const testContent = "test content for dual storage deletion"; const testHash = `test-dual-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with hash uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Check if GCS backup was created const hasGcsBackup = uploadResponse.data.gcs && uploadResponse.data.gcs.startsWith("gs://"); // Delete file by hash const deleteResponse = await deleteFileByHash(testHash); t.is(deleteResponse.status, 200, "Delete should succeed"); t.truthy(deleteResponse.data.deleted, "Should have deletion details"); // Should have at least primary storage deletion const deletionDetails = deleteResponse.data.deleted.deleted; t.true(Array.isArray(deletionDetails), "Deletion details should be an array"); t.true(deletionDetails.length >= 1, "Should have at least primary deletion result"); const primaryDeletion = deletionDetails.find(d => d.provider === 'primary'); t.truthy(primaryDeletion, "Should have primary storage deletion"); // If GCS backup existed, should also have backup deletion if (hasGcsBackup) { const backupDeletion = deletionDetails.find(d => d.provider === 'backup'); t.truthy(backupDeletion, "Should have backup storage deletion when GCS is configured"); } // Verify hash is completely removed const hashCheckAfter = await checkHashExists(testHash); t.is(hashCheckAfter.status, 404, "Hash should not exist after deletion"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should handle malformed hash gracefully", async (t) => { const malformedHashes = ["", " ", "null", "undefined", "{}"]; for (const badHash of malformedHashes) { const deleteResponse = await deleteFileByHash(badHash); t.true( deleteResponse.status === 404 || deleteResponse.status === 400, `Should return 404 or 400 for malformed hash: "${badHash}"` ); } }); test.serial("should prioritize requestId over hash when both provided", async (t) => { const testContent = "test content for priority test"; const testHash = `test-priority-${uuidv4()}`; const requestId = uuidv4(); const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with both hash and requestId uploadResponse = await uploadFile(filePath, requestId, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Delete using both hash and requestId - should delete by requestId (current behavior) const deleteResponse = await axios.delete(`${baseUrl}?hash=${testHash}&requestId=${requestId}`, { validateStatus: (status) => true, timeout: 10000, }); t.is(deleteResponse.status, 200, "Delete should succeed"); t.truthy(deleteResponse.data.body, "Should have deletion body"); t.true(Array.isArray(deleteResponse.data.body), "Deletion body should be array of deleted files"); // Verify hash is gone (because the file was deleted via requestId) const hashCheckAfter = await checkHashExists(testHash); t.is(hashCheckAfter.status, 404, "Hash should not exist after deletion"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should return proper response format for successful deletion", async (t) => { const testContent = "test content for response format"; const testHash = `test-response-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with hash uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Delete file by hash const deleteResponse = await deleteFileByHash(testHash); t.is(deleteResponse.status, 200, "Delete should succeed"); // Verify response structure t.truthy(deleteResponse.data, "Should have response data"); t.truthy(deleteResponse.data.message, "Should have success message"); t.is(deleteResponse.data.deleted.hash, testHash, "Should include deleted hash"); t.truthy(deleteResponse.data.deleted.filename, "Should include filename"); t.truthy(deleteResponse.data.deleted.deleted, "Should include deletion details"); t.true(Array.isArray(deleteResponse.data.deleted.deleted), "Deletion details should be array"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should handle deletion when Redis is temporarily unavailable", async (t) => { const testContent = "test content for Redis failure"; const testHash = `test-redis-fail-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with hash uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Manually corrupt the Redis entry to simulate Redis issues await setFileStoreMap(testHash, { corrupted: "data" }); // Delete file by hash - should handle Redis issues gracefully const deleteResponse = await deleteFileByHash(testHash); // The behavior may vary depending on how Redis failures are handled // It should either succeed with a warning or fail gracefully t.true( deleteResponse.status === 200 || deleteResponse.status === 404 || deleteResponse.status === 500, "Should handle Redis failure gracefully" ); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should delete file uploaded with different filename", async (t) => { const testContent = "test content with special filename"; const testHash = `test-filename-${uuidv4()}`; const specialFilename = "test file with spaces & symbols!@#.txt"; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with special filename and hash const form = new FormData(); form.append("file", fs.createReadStream(filePath), specialFilename); form.append("hash", testHash); uploadResponse = await axios.post(baseUrl, form, { headers: form.getHeaders(), validateStatus: (status) => true, timeout: 15000, }); t.is(uploadResponse.status, 200, "Upload should succeed"); // Delete file by hash const deleteResponse = await deleteFileByHash(testHash); t.is(deleteResponse.status, 200, "Delete should succeed"); t.truthy(deleteResponse.data.deleted.filename, "Should include original filename"); // Verify hash is gone const hashCheckAfter = await checkHashExists(testHash); t.is(hashCheckAfter.status, 404, "Hash should not exist after deletion"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); // Tests for DELETE with hash in request body test.serial("should delete file by hash from request body params", async (t) => { const testContent = "test content for body params deletion"; const testHash = `test-body-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with hash uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Delete file by hash using body params const deleteResponse = await axios.delete(baseUrl, { data: { params: { hash: testHash } }, validateStatus: (status) => true, timeout: 10000, }); t.is(deleteResponse.status, 200, "Delete should succeed"); t.truthy(deleteResponse.data.message, "Should have success message"); t.true(deleteResponse.data.message.includes(testHash), "Message should include hash"); t.is(deleteResponse.data.deleted.hash, testHash, "Should include deleted hash"); // Verify hash is gone const hashCheckAfter = await checkHashExists(testHash); t.is(hashCheckAfter.status, 404, "Hash should not exist after deletion"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should delete file by hash from request body (direct)", async (t) => { const testContent = "test content for direct body deletion"; const testHash = `test-direct-body-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with hash uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Delete file by hash using direct body (not in params) const deleteResponse = await axios.delete(baseUrl, { data: { hash: testHash }, validateStatus: (status) => true, timeout: 10000, }); t.is(deleteResponse.status, 200, "Delete should succeed"); t.truthy(deleteResponse.data.message, "Should have success message"); t.is(deleteResponse.data.deleted.hash, testHash, "Should include deleted hash"); // Verify hash is gone const hashCheckAfter = await checkHashExists(testHash); t.is(hashCheckAfter.status, 404, "Hash should not exist after deletion"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should prioritize query string over body params for hash", async (t) => { const testContent = "test content for priority test"; const queryHash = `test-query-${uuidv4()}`; const bodyHash = `test-body-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with query hash uploadResponse = await uploadFile(filePath, null, queryHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Try to delete with hash in both query and body - query should take priority const deleteResponse = await axios.delete(`${baseUrl}?hash=${queryHash}`, { data: { params: { hash: bodyHash } }, validateStatus: (status) => true, timeout: 10000, }); t.is(deleteResponse.status, 200, "Delete should succeed"); t.is(deleteResponse.data.deleted.hash, queryHash, "Should use query hash, not body hash"); // Verify query hash is gone const queryHashCheck = await checkHashExists(queryHash); t.is(queryHashCheck.status, 404, "Query hash should not exist after deletion"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(queryHash); await removeFromFileStoreMap(bodyHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should delete file by requestId from body params", async (t) => { const testContent = "test content for requestId body deletion"; const requestId = uuidv4(); const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file with requestId uploadResponse = await uploadFile(filePath, requestId, null); t.is(uploadResponse.status, 200, "Upload should succeed"); // Delete file by requestId using body params const deleteResponse = await axios.delete(baseUrl, { data: { params: { requestId: requestId } }, validateStatus: (status) => true, timeout: 10000, }); t.is(deleteResponse.status, 200, "Delete should succeed"); t.truthy(deleteResponse.data.body, "Should have deletion body"); t.true(Array.isArray(deleteResponse.data.body), "Deletion body should be array"); } finally { fs.unlinkSync(filePath); } }); test.serial("should handle standard Azure URL format correctly", async (t) => { const testContent = "test content for standard URL format"; const testHash = `test-standard-url-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); t.truthy(uploadResponse.data.url, "Should have file URL"); // Verify URL format is standard Azure format (container/blob) const url = uploadResponse.data.url; const urlObj = new URL(url); const pathParts = urlObj.pathname.split('/').filter(p => p.length > 0); t.true(pathParts.length >= 2, "URL should have at least container and blob name"); // Delete file - should parse URL correctly const deleteResponse = await deleteFileByHash(testHash); t.is(deleteResponse.status, 200, "Delete should succeed"); // Verify deletion was successful const hashCheckAfter = await checkHashExists(testHash); t.is(hashCheckAfter.status, 404, "Hash should not exist after deletion"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should handle backwards compatibility key removal correctly", async (t) => { const testContent = "test content for legacy key test"; const testHash = `test-legacy-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Manually create a legacy unscoped key to test backwards compatibility const { setFileStoreMap, getFileStoreMap } = await import("../src/redis.js"); const hashResult = await getFileStoreMap(testHash); if (hashResult) { // Create legacy unscoped key (already exists from upload, but verify) await setFileStoreMap(testHash, hashResult); // Verify key exists const legacyExists = await getFileStoreMap(testHash); t.truthy(legacyExists, "Legacy key should exist"); // Delete file - should remove both keys const deleteResponse = await deleteFileByHash(testHash); t.is(deleteResponse.status, 200, "Delete should succeed"); // Verify key is removed const legacyAfter = await getFileStoreMap(testHash); t.falsy(legacyAfter, "Legacy key should be removed"); } } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should not log 'does not exist' when legacy key doesn't exist", async (t) => { const testContent = "test content for no legacy key test"; const testHash = `test-no-legacy-${uuidv4()}`; const filePath = await createTestFile(testContent, "txt"); let uploadResponse; try { // Upload file (this creates only the scoped key, no legacy key) uploadResponse = await uploadFile(filePath, null, testHash); t.is(uploadResponse.status, 200, "Upload should succeed"); // Verify hash exists (no scoping - just hash directly) const { getFileStoreMap } = await import("../src/redis.js"); const hashExists = await getFileStoreMap(testHash); t.truthy(hashExists, "Hash should exist"); // Delete file const deleteResponse = await deleteFileByHash(testHash); t.is(deleteResponse.status, 200, "Delete should succeed"); // Verify hash is removed const hashAfter = await getFileStoreMap(testHash); t.falsy(hashAfter, "Hash should be removed"); } finally { fs.unlinkSync(filePath); try { await removeFromFileStoreMap(testHash); } catch (e) { // Ignore cleanup errors } } }); test.serial("should handle error message for missing hash/requestId correctly", async (t) => { // Test with no parameters at all const deleteResponse1 = await axios.delete(baseUrl, { validateStatus: (status) => true, timeout: 10000, }); t.is(deleteResponse1.status, 400, "Should return 400 for missing parameters"); t.truthy(deleteResponse1.data, "Should have error message"); t.true( deleteResponse1.data.includes("query string or request body"), "Error should mention both query string and request body" ); // Test with empty body const deleteResponse2 = await axios.delete(baseUrl, { data: {}, validateStatus: (status) => true, timeout: 10000, }); t.is(deleteResponse2.status, 400, "Should return 400 for missing parameters"); });