aboutsummaryrefslogtreecommitdiffstats
path: root/packages/e2e_tests/tests
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-07-04 23:58:42 +0100
committerGitHub <noreply@github.com>2025-07-04 23:58:42 +0100
commitd66b3b8619e8fff36c0243f7cc67eef864c5009b (patch)
tree6f555ad31cfc44aebffab1db3edb6134c10878d0 /packages/e2e_tests/tests
parent53b6b3c24d9669ba240c1f9c5fb58672b6cf8666 (diff)
downloadkarakeep-d66b3b8619e8fff36c0243f7cc67eef864c5009b.tar.zst
feat: Add support for S3 as an asset storage layer (#1703)
* feat: Add support for S3 as an asset storage layer. Fixes #305 * some minor fixes * use bulk deletion api * stream the file to s3
Diffstat (limited to 'packages/e2e_tests/tests')
-rw-r--r--packages/e2e_tests/tests/assetdb/assetdb-utils.ts289
-rw-r--r--packages/e2e_tests/tests/assetdb/interface-compliance.test.ts627
-rw-r--r--packages/e2e_tests/tests/assetdb/local-filesystem-store.test.ts228
-rw-r--r--packages/e2e_tests/tests/assetdb/s3-store.test.ts197
4 files changed, 1341 insertions, 0 deletions
diff --git a/packages/e2e_tests/tests/assetdb/assetdb-utils.ts b/packages/e2e_tests/tests/assetdb/assetdb-utils.ts
new file mode 100644
index 00000000..a8e29ab4
--- /dev/null
+++ b/packages/e2e_tests/tests/assetdb/assetdb-utils.ts
@@ -0,0 +1,289 @@
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import {
+ CreateBucketCommand,
+ DeleteBucketCommand,
+ DeleteObjectCommand,
+ ListObjectsV2Command,
+ S3Client,
+} from "@aws-sdk/client-s3";
+
+import {
+ ASSET_TYPES,
+ AssetMetadata,
+ AssetStore,
+ LocalFileSystemAssetStore,
+ S3AssetStore,
+} from "@karakeep/shared/assetdb";
+
+export interface TestAssetData {
+ userId: string;
+ assetId: string;
+ content: Buffer;
+ metadata: AssetMetadata;
+}
+
+export function createTestAssetData(
+ overrides: Partial<TestAssetData> = {},
+): TestAssetData {
+ const defaultData: TestAssetData = {
+ userId: `user_${Math.random().toString(36).substring(7)}`,
+ assetId: `asset_${Math.random().toString(36).substring(7)}`,
+ content: Buffer.from(`Test content ${Math.random()}`),
+ metadata: {
+ contentType: ASSET_TYPES.TEXT_HTML,
+ fileName: "test.html",
+ },
+ };
+
+ return { ...defaultData, ...overrides };
+}
+
+export function createTestImageData(): TestAssetData {
+ // Create a minimal PNG image (1x1 pixel)
+ const pngData = Buffer.from([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
+ 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00,
+ 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0x0f, 0x00, 0x00,
+ 0x01, 0x00, 0x01, 0x5c, 0xc2, 0x8a, 0x8e, 0x00, 0x00, 0x00, 0x00, 0x49,
+ 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
+ ]);
+
+ return createTestAssetData({
+ content: pngData,
+ metadata: {
+ contentType: ASSET_TYPES.IMAGE_PNG,
+ fileName: "test.png",
+ },
+ });
+}
+
+export function createTestPdfData(): TestAssetData {
+ // Create a minimal PDF
+ const pdfContent = `%PDF-1.4
+1 0 obj
+<<
+/Type /Catalog
+/Pages 2 0 R
+>>
+endobj
+
+2 0 obj
+<<
+/Type /Pages
+/Kids [3 0 R]
+/Count 1
+>>
+endobj
+
+3 0 obj
+<<
+/Type /Page
+/Parent 2 0 R
+/MediaBox [0 0 612 792]
+>>
+endobj
+
+xref
+0 4
+0000000000 65535 f
+0000000010 00000 n
+0000000053 00000 n
+0000000125 00000 n
+trailer
+<<
+/Size 4
+/Root 1 0 R
+>>
+startxref
+173
+%%EOF`;
+
+ return createTestAssetData({
+ content: Buffer.from(pdfContent),
+ metadata: {
+ contentType: ASSET_TYPES.APPLICATION_PDF,
+ fileName: "test.pdf",
+ },
+ });
+}
+
+export async function createTempDirectory(): Promise<string> {
+ const tempDir = await fs.promises.mkdtemp(
+ path.join(os.tmpdir(), "assetdb-test-"),
+ );
+ return tempDir;
+}
+
+export async function cleanupTempDirectory(tempDir: string): Promise<void> {
+ try {
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
+ } catch (error) {
+ console.warn(`Failed to cleanup temp directory ${tempDir}:`, error);
+ }
+}
+
+export function createLocalFileSystemStore(
+ rootPath: string,
+): LocalFileSystemAssetStore {
+ return new LocalFileSystemAssetStore(rootPath);
+}
+
+export function createS3Store(bucketName: string): S3AssetStore {
+ const s3Client = new S3Client({
+ region: "us-east-1",
+ endpoint: "http://localhost:9000", // MinIO endpoint for testing
+ credentials: {
+ accessKeyId: "minioadmin",
+ secretAccessKey: "minioadmin",
+ },
+ forcePathStyle: true,
+ });
+
+ return new S3AssetStore(s3Client, bucketName);
+}
+
+export async function createTestBucket(bucketName: string): Promise<S3Client> {
+ const s3Client = new S3Client({
+ region: "us-east-1",
+ endpoint: "http://localhost:9000",
+ credentials: {
+ accessKeyId: "minioadmin",
+ secretAccessKey: "minioadmin",
+ },
+ forcePathStyle: true,
+ });
+
+ try {
+ await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
+ } catch (error: unknown) {
+ const err = error as { name?: string };
+ if (
+ err.name !== "BucketAlreadyOwnedByYou" &&
+ err.name !== "BucketAlreadyExists"
+ ) {
+ throw error;
+ }
+ }
+
+ return s3Client;
+}
+
+export async function cleanupTestBucket(
+ s3Client: S3Client,
+ bucketName: string,
+): Promise<void> {
+ try {
+ // List and delete all objects
+ let continuationToken: string | undefined;
+ do {
+ const listResponse = await s3Client.send(
+ new ListObjectsV2Command({
+ Bucket: bucketName,
+ ContinuationToken: continuationToken,
+ }),
+ );
+
+ if (listResponse.Contents && listResponse.Contents.length > 0) {
+ const deletePromises = listResponse.Contents.map(
+ (obj: { Key?: string }) =>
+ s3Client.send(
+ new DeleteObjectCommand({
+ Bucket: bucketName,
+ Key: obj.Key!,
+ }),
+ ),
+ );
+ await Promise.all(deletePromises);
+ }
+
+ continuationToken = listResponse.NextContinuationToken;
+ } while (continuationToken);
+
+ // Delete the bucket
+ await s3Client.send(new DeleteBucketCommand({ Bucket: bucketName }));
+ } catch (error) {
+ console.warn(`Failed to cleanup S3 bucket ${bucketName}:`, error);
+ }
+}
+
+export async function createTempFile(
+ content: Buffer,
+ fileName: string,
+): Promise<string> {
+ const tempDir = await createTempDirectory();
+ const filePath = path.join(tempDir, fileName);
+ await fs.promises.writeFile(filePath, content);
+ return filePath;
+}
+
+export async function streamToBuffer(
+ stream: NodeJS.ReadableStream,
+): Promise<Buffer> {
+ const chunks: Buffer[] = [];
+ const readable = stream as AsyncIterable<Buffer>;
+
+ for await (const chunk of readable) {
+ chunks.push(chunk);
+ }
+
+ return Buffer.concat(chunks);
+}
+
+export function generateLargeBuffer(sizeInMB: number): Buffer {
+ const sizeInBytes = sizeInMB * 1024 * 1024;
+ const buffer = Buffer.alloc(sizeInBytes);
+
+ // Fill with some pattern to make it compressible but not empty
+ for (let i = 0; i < sizeInBytes; i++) {
+ buffer[i] = i % 256;
+ }
+
+ return buffer;
+}
+
+export async function assertAssetExists(
+ store: AssetStore,
+ userId: string,
+ assetId: string,
+): Promise<void> {
+ const { asset, metadata } = await store.readAsset({ userId, assetId });
+ if (!asset || !metadata) {
+ throw new Error(`Asset ${assetId} for user ${userId} does not exist`);
+ }
+}
+
+export async function assertAssetNotExists(
+ store: AssetStore,
+ userId: string,
+ assetId: string,
+): Promise<void> {
+ try {
+ await store.readAsset({ userId, assetId });
+ throw new Error(`Asset ${assetId} for user ${userId} should not exist`);
+ } catch (error: unknown) {
+ // Expected to throw
+ const err = error as { message?: string };
+ if (err.message?.includes("should not exist")) {
+ throw error;
+ }
+ }
+}
+
+export async function getAllAssetsArray(store: AssetStore): Promise<
+ {
+ userId: string;
+ assetId: string;
+ contentType: string;
+ fileName?: string | null;
+ size: number;
+ }[]
+> {
+ const assets = [];
+ for await (const asset of store.getAllAssets()) {
+ assets.push(asset);
+ }
+ return assets;
+}
diff --git a/packages/e2e_tests/tests/assetdb/interface-compliance.test.ts b/packages/e2e_tests/tests/assetdb/interface-compliance.test.ts
new file mode 100644
index 00000000..d5288c7a
--- /dev/null
+++ b/packages/e2e_tests/tests/assetdb/interface-compliance.test.ts
@@ -0,0 +1,627 @@
+import * as fs from "fs";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { ASSET_TYPES, AssetStore } from "@karakeep/shared/assetdb";
+
+import {
+ assertAssetExists,
+ assertAssetNotExists,
+ cleanupTempDirectory,
+ cleanupTestBucket,
+ createLocalFileSystemStore,
+ createS3Store,
+ createTempDirectory,
+ createTempFile,
+ createTestAssetData,
+ createTestBucket,
+ createTestImageData,
+ createTestPdfData,
+ generateLargeBuffer,
+ getAllAssetsArray,
+ streamToBuffer,
+} from "./assetdb-utils";
+
+interface TestContext {
+ store: AssetStore;
+ cleanup: () => Promise<void>;
+}
+
+async function createLocalContext(): Promise<TestContext> {
+ const tempDir = await createTempDirectory();
+ const store = createLocalFileSystemStore(tempDir);
+
+ return {
+ store,
+ cleanup: async () => {
+ await cleanupTempDirectory(tempDir);
+ },
+ };
+}
+
+async function createS3Context(): Promise<TestContext> {
+ const bucketName = `test-bucket-${Math.random().toString(36).substring(7)}`;
+ const s3Client = await createTestBucket(bucketName);
+ const store = createS3Store(bucketName);
+
+ return {
+ store,
+ cleanup: async () => {
+ await cleanupTestBucket(s3Client, bucketName);
+ },
+ };
+}
+
+describe.each([
+ { name: "LocalFileSystemAssetStore", createContext: createLocalContext },
+ { name: "S3AssetStore", createContext: createS3Context },
+])("AssetStore Interface Compliance - $name", ({ createContext }) => {
+ let context: TestContext;
+
+ beforeEach(async () => {
+ context = await createContext();
+ });
+
+ afterEach(async () => {
+ await context.cleanup();
+ });
+
+ describe("Basic CRUD Operations", () => {
+ it("should save and retrieve an asset", async () => {
+ const testData = createTestAssetData();
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const { asset, metadata } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(asset).toEqual(testData.content);
+ expect(metadata).toEqual(testData.metadata);
+ });
+
+ it("should delete an asset", async () => {
+ const testData = createTestAssetData();
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ await assertAssetExists(context.store, testData.userId, testData.assetId);
+
+ await context.store.deleteAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ await assertAssetNotExists(
+ context.store,
+ testData.userId,
+ testData.assetId,
+ );
+ });
+
+ it("should get asset size", async () => {
+ const testData = createTestAssetData();
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const size = await context.store.getAssetSize({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(size).toBe(testData.content.length);
+ });
+
+ it("should read asset metadata", async () => {
+ const testData = createTestAssetData();
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const metadata = await context.store.readAssetMetadata({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(metadata).toEqual(testData.metadata);
+ });
+ });
+
+ describe("Streaming Operations", () => {
+ it("should create readable stream", async () => {
+ const testData = createTestAssetData();
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const stream = await context.store.createAssetReadStream({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ const streamedContent = await streamToBuffer(stream);
+ expect(streamedContent).toEqual(testData.content);
+ });
+
+ it("should support range requests in streams", async () => {
+ const content = Buffer.from("0123456789abcdef");
+ const testData = createTestAssetData({ content });
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const stream = await context.store.createAssetReadStream({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ start: 5,
+ end: 10,
+ });
+
+ const streamedContent = await streamToBuffer(stream);
+ expect(streamedContent.toString()).toBe("56789a");
+ });
+ });
+
+ describe("Asset Types Support", () => {
+ it("should support all required asset types", async () => {
+ const testCases = [
+ { contentType: ASSET_TYPES.IMAGE_JPEG, fileName: "test.jpg" },
+ { contentType: ASSET_TYPES.IMAGE_PNG, fileName: "test.png" },
+ { contentType: ASSET_TYPES.IMAGE_WEBP, fileName: "test.webp" },
+ { contentType: ASSET_TYPES.APPLICATION_PDF, fileName: "test.pdf" },
+ { contentType: ASSET_TYPES.TEXT_HTML, fileName: "test.html" },
+ { contentType: ASSET_TYPES.VIDEO_MP4, fileName: "test.mp4" },
+ ];
+
+ for (const { contentType, fileName } of testCases) {
+ const testData = createTestAssetData({
+ metadata: { contentType, fileName },
+ });
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const { metadata } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(metadata.contentType).toBe(contentType);
+ expect(metadata.fileName).toBe(fileName);
+ }
+ });
+
+ it("should handle large assets", async () => {
+ const largeContent = generateLargeBuffer(5); // 5MB
+ const testData = createTestAssetData({ content: largeContent });
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const { asset } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(asset.length).toBe(largeContent.length);
+ expect(asset).toEqual(largeContent);
+ });
+
+ it("should reject unsupported asset types", async () => {
+ const testData = createTestAssetData({
+ metadata: {
+ contentType: "unsupported/type",
+ fileName: "test.unsupported",
+ },
+ });
+
+ await expect(
+ context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ }),
+ ).rejects.toThrow("Unsupported asset type");
+ });
+ });
+
+ describe("Bulk Operations", () => {
+ it("should delete all user assets", async () => {
+ const userId = "bulk-test-user";
+ const testAssets = [
+ createTestAssetData({ userId }),
+ createTestAssetData({ userId }),
+ createTestAssetData({ userId }),
+ ];
+ const otherUserAsset = createTestAssetData(); // Different user
+
+ // Save all assets
+ await Promise.all([
+ ...testAssets.map((testData) =>
+ context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ }),
+ ),
+ context.store.saveAsset({
+ userId: otherUserAsset.userId,
+ assetId: otherUserAsset.assetId,
+ asset: otherUserAsset.content,
+ metadata: otherUserAsset.metadata,
+ }),
+ ]);
+
+ // Delete user assets
+ await context.store.deleteUserAssets({ userId });
+
+ // Verify user assets are deleted
+ for (const testData of testAssets) {
+ await assertAssetNotExists(
+ context.store,
+ testData.userId,
+ testData.assetId,
+ );
+ }
+
+ // Verify other user's asset still exists
+ await assertAssetExists(
+ context.store,
+ otherUserAsset.userId,
+ otherUserAsset.assetId,
+ );
+ });
+
+ it("should list all assets", async () => {
+ const testAssets = [
+ createTestAssetData(),
+ createTestImageData(),
+ createTestPdfData(),
+ ];
+
+ // Save all assets
+ await Promise.all(
+ testAssets.map((testData) =>
+ context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ }),
+ ),
+ );
+
+ const assets = await getAllAssetsArray(context.store);
+
+ expect(assets).toHaveLength(3);
+
+ // Verify all assets are present
+ const assetIds = assets.map((a) => a.assetId);
+ for (const testData of testAssets) {
+ expect(assetIds).toContain(testData.assetId);
+ }
+
+ // Verify asset structure
+ for (const asset of assets) {
+ expect(asset).toHaveProperty("userId");
+ expect(asset).toHaveProperty("assetId");
+ expect(asset).toHaveProperty("contentType");
+ expect(asset).toHaveProperty("size");
+ expect(typeof asset.size).toBe("number");
+ expect(asset.size).toBeGreaterThan(0);
+ }
+ });
+ });
+
+ describe("File Operations", () => {
+ it("should save asset from file and delete original file", async () => {
+ const testData = createTestAssetData();
+ const tempFile = await createTempFile(testData.content, "temp-asset.bin");
+
+ // Verify temp file exists before operation
+ expect(
+ await fs.promises
+ .access(tempFile)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ await context.store.saveAssetFromFile({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ assetPath: tempFile,
+ metadata: testData.metadata,
+ });
+
+ // Verify temp file was deleted
+ expect(
+ await fs.promises
+ .access(tempFile)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(false);
+
+ // Verify asset was saved correctly
+ const { asset, metadata } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(asset).toEqual(testData.content);
+ expect(metadata).toEqual(testData.metadata);
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("should throw error for non-existent asset read", async () => {
+ await expect(
+ context.store.readAsset({
+ userId: "non-existent-user",
+ assetId: "non-existent-asset",
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("should throw error for non-existent asset metadata", async () => {
+ await expect(
+ context.store.readAssetMetadata({
+ userId: "non-existent-user",
+ assetId: "non-existent-asset",
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("should throw error for non-existent asset size", async () => {
+ await expect(
+ context.store.getAssetSize({
+ userId: "non-existent-user",
+ assetId: "non-existent-asset",
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("should handle deleting non-existent asset gracefully", async () => {
+ // Filesystem implementation throws errors for non-existent files
+ await expect(
+ context.store.deleteAsset({
+ userId: "non-existent-user",
+ assetId: "non-existent-asset",
+ }),
+ ).resolves.not.toThrow();
+ });
+
+ it("should handle deletion of non-existent user directory gracefully", async () => {
+ // Should not throw error when user directory doesn't exist
+ await expect(
+ context.store.deleteUserAssets({ userId: "non-existent-user" }),
+ ).resolves.not.toThrow();
+ });
+
+ it("should handle non-existent asset stream appropriately", async () => {
+ const streamResult = context.store.createAssetReadStream({
+ userId: "non-existent-user",
+ assetId: "non-existent-asset",
+ });
+
+ await expect(streamResult).rejects.toThrow();
+ });
+ });
+
+ describe("Data Integrity", () => {
+ it("should preserve binary data integrity", async () => {
+ const testData = createTestImageData();
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const { asset } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ // Verify exact binary match
+ expect(asset).toEqual(testData.content);
+
+ // Verify PNG header is intact
+ expect(asset[0]).toBe(0x89);
+ expect(asset[1]).toBe(0x50);
+ expect(asset[2]).toBe(0x4e);
+ expect(asset[3]).toBe(0x47);
+ });
+
+ it("should preserve metadata exactly", async () => {
+ const testData = createTestAssetData({
+ metadata: {
+ contentType: ASSET_TYPES.APPLICATION_PDF,
+ fileName: "test-document.pdf",
+ },
+ });
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const { metadata } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(metadata).toEqual(testData.metadata);
+ expect(metadata.contentType).toBe(ASSET_TYPES.APPLICATION_PDF);
+ expect(metadata.fileName).toBe("test-document.pdf");
+ });
+
+ it("should handle null fileName correctly", async () => {
+ const testData = createTestAssetData({
+ metadata: {
+ contentType: ASSET_TYPES.TEXT_HTML,
+ fileName: null,
+ },
+ });
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const { metadata } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(metadata.fileName).toBeNull();
+ });
+ });
+
+ describe("Concurrent Operations", () => {
+ it("should handle concurrent saves safely", async () => {
+ const testAssets = Array.from({ length: 5 }, () => createTestAssetData());
+
+ await Promise.all(
+ testAssets.map((testData) =>
+ context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ }),
+ ),
+ );
+
+ // Verify all assets were saved correctly
+ for (const testData of testAssets) {
+ const { asset, metadata } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(asset).toEqual(testData.content);
+ expect(metadata).toEqual(testData.metadata);
+ }
+ });
+
+ it("should handle concurrent reads safely", async () => {
+ const testData = createTestAssetData();
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ // Perform multiple concurrent reads
+ const readPromises = Array.from({ length: 10 }, () =>
+ context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ }),
+ );
+
+ const results = await Promise.all(readPromises);
+
+ // Verify all reads returned the same data
+ for (const { asset, metadata } of results) {
+ expect(asset).toEqual(testData.content);
+ expect(metadata).toEqual(testData.metadata);
+ }
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle empty assets", async () => {
+ const testData = createTestAssetData({
+ content: Buffer.alloc(0),
+ });
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const { asset } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(asset.length).toBe(0);
+
+ const size = await context.store.getAssetSize({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(size).toBe(0);
+ });
+
+ it("should handle special characters in user and asset IDs", async () => {
+ const testData = createTestAssetData({
+ userId: "user-with-special_chars.123",
+ assetId: "asset_with-special.chars_456",
+ });
+
+ await context.store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const { asset, metadata } = await context.store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(asset).toEqual(testData.content);
+ expect(metadata).toEqual(testData.metadata);
+ });
+ });
+});
diff --git a/packages/e2e_tests/tests/assetdb/local-filesystem-store.test.ts b/packages/e2e_tests/tests/assetdb/local-filesystem-store.test.ts
new file mode 100644
index 00000000..36ff837f
--- /dev/null
+++ b/packages/e2e_tests/tests/assetdb/local-filesystem-store.test.ts
@@ -0,0 +1,228 @@
+import * as fs from "fs";
+import * as path from "path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { LocalFileSystemAssetStore } from "@karakeep/shared/assetdb";
+
+import {
+ assertAssetNotExists,
+ cleanupTempDirectory,
+ createLocalFileSystemStore,
+ createTempDirectory,
+ createTestAssetData,
+} from "./assetdb-utils";
+
+describe("LocalFileSystemAssetStore - Filesystem-Specific Behaviors", () => {
+ let tempDir: string;
+ let store: LocalFileSystemAssetStore;
+
+ beforeEach(async () => {
+ tempDir = await createTempDirectory();
+ store = createLocalFileSystemStore(tempDir);
+ });
+
+ afterEach(async () => {
+ await cleanupTempDirectory(tempDir);
+ });
+
+ describe("File System Structure", () => {
+ it("should create correct directory structure and files", async () => {
+ const testData = createTestAssetData();
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ // Verify directory structure
+ const assetDir = path.join(tempDir, testData.userId, testData.assetId);
+ expect(
+ await fs.promises
+ .access(assetDir)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ // Verify asset.bin file
+ const assetFile = path.join(assetDir, "asset.bin");
+ expect(
+ await fs.promises
+ .access(assetFile)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ // Verify metadata.json file
+ const metadataFile = path.join(assetDir, "metadata.json");
+ expect(
+ await fs.promises
+ .access(metadataFile)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ // Verify file contents
+ const savedContent = await fs.promises.readFile(assetFile);
+ expect(savedContent).toEqual(testData.content);
+
+ const savedMetadata = JSON.parse(
+ await fs.promises.readFile(metadataFile, "utf8"),
+ );
+ expect(savedMetadata).toEqual(testData.metadata);
+ });
+
+ it("should create nested directory structure for user/asset hierarchy", async () => {
+ const userId = "user123";
+ const assetId = "asset456";
+ const testData = createTestAssetData({ userId, assetId });
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ // Verify the exact directory structure
+ const userDir = path.join(tempDir, userId);
+ const assetDir = path.join(userDir, assetId);
+
+ expect(
+ await fs.promises
+ .access(userDir)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ expect(
+ await fs.promises
+ .access(assetDir)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ // Verify files exist in the correct location
+ expect(
+ await fs.promises
+ .access(path.join(assetDir, "asset.bin"))
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ expect(
+ await fs.promises
+ .access(path.join(assetDir, "metadata.json"))
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+ });
+ });
+
+ describe("Directory Cleanup", () => {
+ it("should remove entire asset directory when deleting asset", async () => {
+ const testData = createTestAssetData();
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const assetDir = path.join(tempDir, testData.userId, testData.assetId);
+
+ // Verify directory exists
+ expect(
+ await fs.promises
+ .access(assetDir)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ await store.deleteAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ // Verify entire directory was removed
+ expect(
+ await fs.promises
+ .access(assetDir)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(false);
+
+ await assertAssetNotExists(store, testData.userId, testData.assetId);
+ });
+
+ it("should remove entire user directory when deleting all user assets", async () => {
+ const userId = "test-user";
+ const testData1 = createTestAssetData({ userId });
+ const testData2 = createTestAssetData({ userId });
+
+ await Promise.all([
+ store.saveAsset({
+ userId: testData1.userId,
+ assetId: testData1.assetId,
+ asset: testData1.content,
+ metadata: testData1.metadata,
+ }),
+ store.saveAsset({
+ userId: testData2.userId,
+ assetId: testData2.assetId,
+ asset: testData2.content,
+ metadata: testData2.metadata,
+ }),
+ ]);
+
+ const userDir = path.join(tempDir, userId);
+
+ // Verify user directory exists
+ expect(
+ await fs.promises
+ .access(userDir)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(true);
+
+ await store.deleteUserAssets({ userId });
+
+ // Verify entire user directory was removed
+ expect(
+ await fs.promises
+ .access(userDir)
+ .then(() => true)
+ .catch(() => false),
+ ).toBe(false);
+ });
+ });
+
+ describe("File System Permissions", () => {
+ it("should create directories with appropriate permissions", async () => {
+ const testData = createTestAssetData();
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const userDir = path.join(tempDir, testData.userId);
+ const assetDir = path.join(userDir, testData.assetId);
+
+ // Verify directories are readable and writable
+ const userStats = await fs.promises.stat(userDir);
+ const assetStats = await fs.promises.stat(assetDir);
+
+ expect(userStats.isDirectory()).toBe(true);
+ expect(assetStats.isDirectory()).toBe(true);
+
+ // Verify we can read and write to the directories
+ await fs.promises.access(userDir, fs.constants.R_OK | fs.constants.W_OK);
+ await fs.promises.access(assetDir, fs.constants.R_OK | fs.constants.W_OK);
+ });
+ });
+});
diff --git a/packages/e2e_tests/tests/assetdb/s3-store.test.ts b/packages/e2e_tests/tests/assetdb/s3-store.test.ts
new file mode 100644
index 00000000..c573750e
--- /dev/null
+++ b/packages/e2e_tests/tests/assetdb/s3-store.test.ts
@@ -0,0 +1,197 @@
+import { HeadObjectCommand, S3Client } from "@aws-sdk/client-s3";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { S3AssetStore } from "@karakeep/shared/assetdb";
+
+import {
+ assertAssetExists,
+ cleanupTestBucket,
+ createS3Store,
+ createTestAssetData,
+ createTestBucket,
+} from "./assetdb-utils";
+
+describe("S3AssetStore - S3-Specific Behaviors", () => {
+ let bucketName: string;
+ let s3Client: S3Client;
+ let store: S3AssetStore;
+
+ beforeEach(async () => {
+ bucketName = `test-bucket-${Math.random().toString(36).substring(7)}`;
+ s3Client = await createTestBucket(bucketName);
+ store = createS3Store(bucketName);
+ });
+
+ afterEach(async () => {
+ await cleanupTestBucket(s3Client, bucketName);
+ });
+
+ describe("S3 Object Structure", () => {
+ it("should store asset as single object with user-defined metadata", async () => {
+ const testData = createTestAssetData();
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ // Verify the object exists with the expected key structure
+ const objectKey = `${testData.userId}/${testData.assetId}`;
+ const headResponse = await s3Client.send(
+ new HeadObjectCommand({
+ Bucket: bucketName,
+ Key: objectKey,
+ }),
+ );
+
+ // Verify metadata is stored in S3 user-defined metadata
+ expect(headResponse.Metadata).toBeDefined();
+ expect(headResponse.Metadata!["x-amz-meta-content-type"]).toBe(
+ testData.metadata.contentType,
+ );
+ expect(headResponse.Metadata!["x-amz-meta-file-name"]).toBe(
+ testData.metadata.fileName || "",
+ );
+
+ // Verify content type is set correctly on the S3 object
+ expect(headResponse.ContentType).toBe(testData.metadata.contentType);
+ });
+
+ it("should handle null fileName in metadata correctly", async () => {
+ const testData = createTestAssetData({
+ metadata: {
+ contentType: "text/html",
+ fileName: null,
+ },
+ });
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ const objectKey = `${testData.userId}/${testData.assetId}`;
+ const headResponse = await s3Client.send(
+ new HeadObjectCommand({
+ Bucket: bucketName,
+ Key: objectKey,
+ }),
+ );
+
+ // Verify null fileName are not stored in S3 metadata
+ expect(headResponse.Metadata!["x-amz-meta-file-name"]).toBeUndefined();
+
+ // Verify reading back gives us null fileName
+ const metadata = await store.readAssetMetadata({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+ expect(metadata.fileName).toBeNull();
+ });
+ });
+
+ describe("S3 Key Structure", () => {
+ it("should use clean userId/assetId key structure", async () => {
+ const userId = "user123";
+ const assetId = "asset456";
+ const testData = createTestAssetData({ userId, assetId });
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ // Verify the exact key structure
+ const expectedKey = `${userId}/${assetId}`;
+ const headResponse = await s3Client.send(
+ new HeadObjectCommand({
+ Bucket: bucketName,
+ Key: expectedKey,
+ }),
+ );
+
+ expect(headResponse.ContentLength).toBe(testData.content.length);
+ });
+
+ it("should handle special characters in user and asset IDs", async () => {
+ const userId = "user/with/slashes";
+ const assetId = "asset-with-special-chars_123";
+ const testData = createTestAssetData({ userId, assetId });
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ await assertAssetExists(store, testData.userId, testData.assetId);
+ });
+ });
+
+ describe("S3 Eventual Consistency", () => {
+ it("should handle immediate read after write (MinIO strong consistency)", async () => {
+ const testData = createTestAssetData();
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ // Immediately try to read - should work with MinIO's strong consistency
+ const { asset, metadata } = await store.readAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(asset).toEqual(testData.content);
+ expect(metadata).toEqual(testData.metadata);
+ });
+ });
+
+ describe("S3 Metadata Conversion", () => {
+ it("should correctly convert between AssetMetadata and S3 metadata", async () => {
+ const testCases = [
+ {
+ contentType: "image/jpeg",
+ fileName: "test-image.jpg",
+ },
+ {
+ contentType: "application/pdf",
+ fileName: "document.pdf",
+ },
+ {
+ contentType: "text/html",
+ fileName: null,
+ },
+ ];
+
+ for (const metadata of testCases) {
+ const testData = createTestAssetData({ metadata });
+
+ await store.saveAsset({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ asset: testData.content,
+ metadata: testData.metadata,
+ });
+
+ // Verify metadata round-trip
+ const retrievedMetadata = await store.readAssetMetadata({
+ userId: testData.userId,
+ assetId: testData.assetId,
+ });
+
+ expect(retrievedMetadata).toEqual(testData.metadata);
+ }
+ });
+ });
+});