diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e400bd48..44909740 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,7 @@ { "recommendations": [ - "svelte.svelte-vscode" + "svelte.svelte-vscode", + "biomejs.biome", + "vitest.explorer" ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 3d85cd45..185df4db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,15 @@ { "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFormatter": "biomejs.biome", "editor.tabSize": 4 }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFormatter": "biomejs.biome", "editor.tabSize": 4 }, + "[svelte]": { + "editor.defaultFormatter": "biomejs.biome" + }, "typescript.preferences.preferTypeOnlyAutoImports": true, "js/ts.implicitProjectConfig.target": "ESNext", "typescript.preferences.importModuleSpecifier": "non-relative", diff --git a/backend/.gitignore b/backend/.gitignore index 75691dc7..878f8f64 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -23,6 +23,7 @@ node_modules/ # env .env.production .dev.vars +secret.* # logs logs/ diff --git a/backend/.vscode b/backend/.vscode new file mode 120000 index 00000000..18144664 --- /dev/null +++ b/backend/.vscode @@ -0,0 +1 @@ +../.vscode \ No newline at end of file diff --git a/backend/.vscode/extensions.json b/backend/.vscode/extensions.json deleted file mode 100644 index 974188b8..00000000 --- a/backend/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["esbenp.prettier-vscode"] -} diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json deleted file mode 100644 index 6951acd6..00000000 --- a/backend/.vscode/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "typescript.preferences.preferTypeOnlyAutoImports": true, - "js/ts.implicitProjectConfig.target": "ESNext", - "typescript.preferences.importModuleSpecifier": "non-relative", - "javascript.preferences.importModuleSpecifier": "non-relative" -} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index d4e95419..86513b21 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -4,10 +4,11 @@ This file provides guidance for AI coding agents working on the backend of this ## Build and Test Commands -- **Run the application**: `pnpm run dev` (from the `backend` directory) or `pnpm run dev:be` (from the root directory) +- **Run the application**:`pnpm run -w dev:be` - **Run tests**: Currently no tests. -- **Build the application**: `pnpm run build` (from the `backend` directory) or `pnpm run build:be` (from the root directory) -- **Format code**: `pnpm run format` (from the `backend` directory) or `pnpm run format:be` (from the root directory) +- **Build the application**: `pnpm run -w build:be` +- **Format code**: `pnpm run -w format:be` +- **Lint code**: `pnpm run -w lint:be` ## Backend Development Guidelines @@ -16,16 +17,20 @@ This file provides guidance for AI coding agents working on the backend of this - Follow the style of the existing codebase. - Maintain consistency in indentation, variable names, and function names. - Use JSDoc for documentation. - - Firing/receiving events MUST be documented with JSDoc! + - Firing/receiving events MUST be documented with JSDoc! - Don't use `any` type if possible; prefer specific types or generics. - Use `async/await` for asynchronous code instead of `.then()`. We have ESNext support! - This is NOT a Node.js project; it uses Web Workers. Use Web APIs instead of Node.js APIs. - Use libraries instead of reinventing the wheel. - - One exception: If Web APIs are available with the same functionality, prefer them over libraries. - (example: `fetch` instead of `axios` library, `crypto.randomUUID` instead of `uuid` library). + - One exception: If Web APIs are available with the same functionality, prefer them over libraries. + (example: `fetch` instead of `axios` library, `crypto.randomUUID` instead of `uuid` library). +- Use `satisfies` operator to ensure object types instead of type assertions (`as Type`) if possible. ### Developer's Guide - Make code stateless to reduce trouble with containers, auto-scaling, etc. - Use `pnpm add` instead of editing `package.json` directly. - Use `import.meta.env`(injected at build time) or `c.env`(injected at runtime, per HTTP call) for environment variables. + +### Documents +- Hono: https://hono.dev/llms.txt diff --git a/backend/biome.jsonc b/backend/biome.jsonc new file mode 100644 index 00000000..a1bba40d --- /dev/null +++ b/backend/biome.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noConstEnum": "off" // We don't need to iterate it + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 80, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always" + } + } +} diff --git a/backend/package.json b/backend/package.json index 99462571..0fcc32d3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,8 +17,11 @@ "deploy": "wrangler deploy", "cf-typegen": "wrangler types --env-interface CloudflareBindings", "postinstall": "pnpm run cf-typegen", - "format": "node --experimental-strip-types ../node_modules/prettier/bin/prettier.cjs --write .", - "auth": "pnpx http-server -p 5000 -c-1 tools/" + "format": "biome format --write .", + "lint": "biome lint .", + "check": "biome check --write .", + "auth": "pnpx http-server -p 5000 -c-1 tools/", + "test": "vitest" }, "engines": { "node": ">=20" @@ -31,15 +34,20 @@ "@hono/zod-openapi": "^1.1.3", "@scalar/hono-api-reference": "^0.9.20", "aws4fetch": "^1.0.20", + "firebase-rest-firestore": "^1.5.0", "hono": "^4.9.9", "hono-openapi": "^1.1.0", "zod": "^4.1.11" }, "devDependencies": { + "@biomejs/biome": "^2.3.8", "@cloudflare/vite-plugin": "^1.2.3", + "@cloudflare/vitest-pool-workers": "^0.10.11", + "@vitest/ui": "^3.2.4", "consola": "^3.4.2", - "vite": "^6.3.5", + "vite": "^7.2.1", "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4", "wrangler": "^4.17.0" } } diff --git a/backend/prettier.config.ts b/backend/prettier.config.ts deleted file mode 100644 index a2ab15cd..00000000 --- a/backend/prettier.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type Config } from "prettier"; - -const config: Config = { - trailingComma: "es5", - tabWidth: 4, -}; - -export default config; diff --git a/backend/src/adapters/StorageClientBase.ts b/backend/src/adapters/StorageClientBase.ts index 0ed6ed25..e6d946e6 100644 --- a/backend/src/adapters/StorageClientBase.ts +++ b/backend/src/adapters/StorageClientBase.ts @@ -1,4 +1,45 @@ -import { DataType } from "@/schema"; +import type { DataType } from "@/schema"; + +/** + * Pagination options for querying data items. + */ +export type PaginationOptions = { + /** + * Number of items to return per page. + */ + limit: number; + + /** + * Token for retrieving the next page of results. + * This is typically returned from a previous query. + */ + pageToken?: string; +}; +export const paginationOptionsDefault: PaginationOptions = { + limit: 10, +}; +/** + * Result of a paginated query. + */ +export type PaginationResult = { + /** + * Array of items for the current page. + */ + items: T[]; + + /** + * Token for retrieving the next page of results. + * If null or undefined, there are no more pages. + * It is typically(not nececcary) alphanumeric value. + */ + nextPageToken?: string; + + /** + * Total number of items (if available). + * This might not be supported by all implementations. + */ + totalCount?: number; +}; /** * Client for performing blob storage operations. @@ -6,7 +47,7 @@ import { DataType } from "@/schema"; * Implementations should return a publicly accessible string (e.g. URL or key) * from upload, and must support reading and deletion of blobs by that string. */ -export interface BaseBlobStorageClient { +export type BaseBlobStorageClient = { /** * Upload a binary buffer to storage. * @param buffer - The data to upload. @@ -15,7 +56,7 @@ export interface BaseBlobStorageClient { */ upload( buffer: ArrayBuffer | Uint8Array, - contentType?: string + contentType?: string, ): Promise; /** @@ -31,13 +72,13 @@ export interface BaseBlobStorageClient { * @returns A promise that resolves when deletion completes. */ delete(url: string): Promise; -} +}; /** * Client interface for data (domain objects) database operations. * * Provides methods to get, query, list, create (put), and delete data items. */ -export interface BaseDataDBClient { +export type BaseDataDBClient = { /** * Get a data item by id. * @param id - Data id. @@ -48,16 +89,24 @@ export interface BaseDataDBClient { /** * Query data items by name. * @param name - Name to query by. - * @returns Array of matching data items. + * @param options [options=options] - Pagination options. + * @returns Paginated result of matching data items. */ - queryByName(name: string): Promise; + queryByName( + name: string, + options: PaginationOptions, + ): Promise>; /** * List data items with optional ordering. * @param order - Optional ordering hint. - * @returns Array of data items. + * @param options - Pagination options. + * @returns Paginated result of data items. */ - list(order?: DataListOrder): Promise; + list( + order?: DataListOrder, + options?: PaginationOptions, + ): Promise>; /** * Create a new data item. @@ -86,14 +135,14 @@ export interface BaseDataDBClient { * @returns The updated data item. */ update(item: Partial & { id: DataType["id"] }): Promise; -} +}; /** * Ordering options for listing data items. * * @enum {string} */ -export enum DataListOrder { +export const enum DataListOrder { /** * Newest first */ diff --git a/backend/src/adapters/client.ts b/backend/src/adapters/client.ts index 04b6e4a8..60c8c30c 100644 --- a/backend/src/adapters/client.ts +++ b/backend/src/adapters/client.ts @@ -5,11 +5,11 @@ * loading unnecessary code in environments that don't need it. */ -import { RuntimeSecret, RuntimeVariable } from "@/types"; -import { +import type { BaseBlobStorageClient, BaseDataDBClient, } from "@/adapters/StorageClientBase"; +import type { RuntimeSecret, RuntimeVariable } from "@/environmentTypes"; /** * Environment variables required by the database and blob storage clients. @@ -23,18 +23,23 @@ let cachedBlobClient: BaseBlobStorageClient | null = null; /** * Dynamically imports and returns the DatabaseClient. - * Temporarily using InMemoryBlob as a placeholder. + * Try AzureCosmosDB, then FirebaseFirestore. + * Temporarily using InMemoryBlob as fallback. * It is created once and cached for subsequent calls. */ export async function DataDBClient(env: DBEnv): Promise { if (cachedDBClient) return cachedDBClient; + let module: Promise<{ + default: new (env: DBEnv) => BaseDataDBClient; + }>; if (env.SECRET_AZURE_COSMOSDB_CONNECTION_STRING) { - cachedDBClient = new (await import("./vendor/AzureCosmosDB")).default( - env - ); + module = import("./vendor/AzureCosmosDB"); + } else if (env.SECRET_FIREBASE_PROJECT_ID) { + module = import("./vendor/FirebaseFirestore"); } else { - cachedDBClient = new (await import("./vendor/InMemoryDB")).default(env); + module = import("./vendor/InMemoryDB"); } + cachedDBClient = new (await module).default(env); return cachedDBClient; } @@ -45,11 +50,11 @@ export async function DataDBClient(env: DBEnv): Promise { export async function BlobClient(env: DBEnv): Promise { if (cachedBlobClient) return cachedBlobClient; if (env.SECRET_S3_BUCKET_NAME) { - return (cachedBlobClient = new ( + cachedBlobClient = new ( await import("./vendor/S3CompatibleBlob") - ).default(env)); + ).default(env); + return cachedBlobClient; } - return (cachedBlobClient = new ( - await import("./vendor/InMemoryBlob") - ).default(env)); + cachedBlobClient = new (await import("./vendor/InMemoryBlob")).default(env); + return cachedBlobClient; } diff --git a/backend/src/adapters/vendor/AzureCosmosDB.ts b/backend/src/adapters/vendor/AzureCosmosDB.ts index 000c9599..9093569c 100644 --- a/backend/src/adapters/vendor/AzureCosmosDB.ts +++ b/backend/src/adapters/vendor/AzureCosmosDB.ts @@ -1,7 +1,12 @@ -import { Container, CosmosClient, Database } from "@azure/cosmos"; -import { DataType } from "@/schema"; -import { DBEnv } from "@/adapters/client"; -import { BaseDataDBClient, DataListOrder } from "@/adapters/StorageClientBase"; +import { type Container, CosmosClient, type Database } from "@azure/cosmos"; +import { + type BaseDataDBClient, + DataListOrder, + type PaginationOptions, + type PaginationResult, +} from "@/adapters/StorageClientBase"; +import type { VendorSecretEnv } from "@/environmentTypes"; +import type { DataType } from "@/schema"; /** * An adapter for Azure Cosmos DB that implements the BaseDataDBClient interface. @@ -14,33 +19,42 @@ export default class AzureCosmosDB implements BaseDataDBClient { private database: Database; private container: Container; private containerName: string; - constructor(env: DBEnv) { + constructor(env: VendorSecretEnv["azure"]) { if (!env.SECRET_AZURE_COSMOSDB_CONNECTION_STRING) { throw new Error( - "Azure Cosmos DB environment variables are not properly set" + "Azure Cosmos DB environment variables are not properly set", ); } this.client = new CosmosClient( - env.SECRET_AZURE_COSMOSDB_CONNECTION_STRING + env.SECRET_AZURE_COSMOSDB_CONNECTION_STRING, ); this.database = this.client.database( - env.SECRET_AZURE_COSMOSDB_DATABASE_NAME + env.SECRET_AZURE_COSMOSDB_DATABASE_NAME, ); this.containerName = env.SECRET_AZURE_COSMOSDB_CONTAINER_NAME; this.container = this.database.container(this.containerName); } + private validateId(id: string): void { + if (!id) throw new Error("Item ID cannot be empty"); + // Cosmos DB illegal chars: /, \, ?, # + if (/[/\\?#]/.test(id)) { + throw new Error("Item ID contains invalid characters"); + } + } + async bumpDownloadCount(id: string): Promise { + this.validateId(id); + const itemRef = this.container.item(id); // Use Cosmos DB patch operation to increment atomically when available try { // Partial patch: increment downloadCount by 1 - await this.container - .item(id) - .patch([{ op: "incr", path: "/downloadCount", value: 1 }]); + await itemRef.patch([ + { op: "incr", path: "/downloadCount", value: 1 }, + ]); return; - } catch (e) { + } catch (_e) { // Fallback to read/replace if patch is not supported in the environment - const itemRef = this.container.item(id); const readRes = await itemRef.read(); const existing = readRes.resource; if (!existing) return; @@ -54,15 +68,46 @@ export default class AzureCosmosDB implements BaseDataDBClient { } async get(id: string): Promise { + this.validateId(id); const doc = await this.container.item(id).read(); return doc.resource ?? null; } - async queryByName(name: string): Promise { + async queryByName( + name: string, + options?: PaginationOptions, + ): Promise> { + if (!name) return { items: [] }; + + // Set default limit + const limit = options?.limit || 10; + const offset = options?.pageToken ? parseInt(options.pageToken, 10) : 0; + //It is fuzzy search. const { resources } = await this.container.items .query({ - query: `SELECT * FROM c WHERE CONTAINS(LOWER(c.name), LOWER(@name))`, + query: `SELECT * FROM c WHERE CONTAINS(LOWER(c.name), LOWER(@name)) ORDER BY c.id OFFSET @offset LIMIT @limit`, + parameters: [ + { + name: "@name", + value: name, + }, + { + name: "@offset", + value: offset, + }, + { + name: "@limit", + value: limit, + }, + ], + }) + .fetchAll(); + + // Get total count for pagination metadata + const { resources: countResources } = await this.container.items + .query<{ count: number }>({ + query: `SELECT VALUE COUNT(1) FROM c WHERE CONTAINS(LOWER(c.name), LOWER(@name))`, parameters: [ { name: "@name", @@ -71,27 +116,79 @@ export default class AzureCosmosDB implements BaseDataDBClient { ], }) .fetchAll(); - return resources; + + const totalCount = countResources[0]?.count || 0; + + // Determine if there's a next page + let nextPageToken: string | undefined; + if (offset + limit < totalCount) { + nextPageToken = String(offset + limit); + } + + return { + items: resources, + nextPageToken, + totalCount, + }; } - async list(order?: DataListOrder): Promise { + async list( + order?: DataListOrder, + options?: PaginationOptions, + ): Promise> { + // Set default limit + const limit = options?.limit || 10; + const offset = options?.pageToken ? parseInt(options.pageToken, 10) : 0; + let orderBy = `ORDER BY c.`; if (order === DataListOrder.NewestFirst) { orderBy += "uploadedAt DESC"; } else if (order === DataListOrder.DownloadsFirst) { orderBy = "downloadCount DESC"; - } else orderBy = ""; - const query = `SELECT * FROM c ${orderBy}`; + } else { + // Default order by id to ensure consistent pagination + orderBy = "c.id"; + } + + const query = `SELECT * FROM c ${orderBy} OFFSET @offset LIMIT @limit`; console.log("CosmosDB list query:", query); - //(await this.client.databases.query("").fetchAll()).resources - return ( - await this.container.items - .query({ - query: query, - }) - .fetchAll() - ).resources; + const { resources } = await this.container.items + .query({ + query: query, + parameters: [ + { + name: "@offset", + value: offset, + }, + { + name: "@limit", + value: limit, + }, + ], + }) + .fetchAll(); + + // Get total count for pagination metadata + const { resources: countResources } = await this.container.items + .query<{ count: number }>({ + query: "SELECT VALUE COUNT(1) FROM c", + }) + .fetchAll(); + + const totalCount = countResources[0]?.count || 0; + + // Determine if there's a next page + let nextPageToken: string | undefined; + if (offset + limit < totalCount) { + nextPageToken = String(offset + limit); + } + + return { + items: resources, + nextPageToken, + totalCount, + }; } async put(item: Omit): Promise { const id = crypto.randomUUID(); @@ -101,8 +198,9 @@ export default class AzureCosmosDB implements BaseDataDBClient { throw new Error("Failed to create item in Cosmos DB"); } async update( - item: Partial & { id: DataType["id"] } + item: Partial & { id: DataType["id"] }, ): Promise { + this.validateId(item.id); // Replace the existing item with the provided one const id = item.id; const itemRef = this.container.item(id); @@ -117,6 +215,7 @@ export default class AzureCosmosDB implements BaseDataDBClient { throw new Error("Failed to update item in Cosmos DB"); } async delete(id: string): Promise { + this.validateId(id); await this.container.item(id).delete(); } } diff --git a/backend/src/adapters/vendor/FirebaseFirestore.ts b/backend/src/adapters/vendor/FirebaseFirestore.ts new file mode 100644 index 00000000..68e1d2eb --- /dev/null +++ b/backend/src/adapters/vendor/FirebaseFirestore.ts @@ -0,0 +1,267 @@ +import { + type CollectionReference, + createFirestoreClient, + type Query, +} from "firebase-rest-firestore"; +import { + type BaseDataDBClient, + DataListOrder, + type PaginationOptions, + type PaginationResult, +} from "@/adapters/StorageClientBase"; +import type { VendorSecretEnv } from "@/environmentTypes"; +import type { DataType } from "@/schema"; + +/** + * FirebaseFirestoreClient is an implementation of BaseDataDBClient + * that interacts with Google Firebase Firestore to perform CRUD operations + * on DataType objects. + * + * Hierarchy: + * - Collection: defaults to "data", can be customized via SECRET_FIRESTORE_COLLECTION_NAME + * - Documents: Each document represents a DataType object.- + */ +export default class FirebaseFirestoreClient implements BaseDataDBClient { + private readonly db: ReturnType; + private readonly collectionName: string; + + /** + * Initializes the FirebaseFirestoreClient. + * + * @param env - The environment variables containing Firebase credentials. + * @throws {Error} If required Firebase credentials are missing. + */ + constructor(env: VendorSecretEnv["firebase"]) { + if ( + !env.SECRET_FIREBASE_PROJECT_ID || + !env.SECRET_FIREBASE_CLIENT_EMAIL || + !env.SECRET_FIREBASE_PRIVATE_KEY + ) { + throw new Error( + "Firebase credentials (PROJECT_ID, CLIENT_EMAIL, PRIVATE_KEY) are not fully defined in the environment.", + ); + } + + this.db = createFirestoreClient({ + projectId: env.SECRET_FIREBASE_PROJECT_ID, + clientEmail: env.SECRET_FIREBASE_CLIENT_EMAIL, + privateKey: env.SECRET_FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"), + }); + this.collectionName = env.SECRET_FIRESTORE_COLLECTION_NAME ?? "data"; + } + + /** + * Normalizes data retrieved from Firestore to match the DataType interface. + * Handles type conversion for numeric fields that might be returned as strings. + * + * @param id - The document ID. + * @param data - The raw data object from Firestore. + * @returns The normalized DataType object. + */ + + // biome-ignore lint/suspicious/noExplicitAny: Firestore data can be any + private normalizeData(id: string, data: any): DataType { + return { + ...data, + id, + // Ensure number fields are actual numbers + downloadCount: data.downloadCount ? Number(data.downloadCount) : 0, + uploadedAt: data.uploadedAt ? Number(data.uploadedAt) : undefined, + } satisfies DataType; + } + + /** + * Retrieves a document by its ID. + * + * @param id - The unique identifier of the document. + * @returns A promise that resolves to the document data or null if not found. + */ + async get(id: string): Promise { + try { + const doc = await this.db + .collection(this.collectionName) + .doc(id) + .get(); + if (!doc.exists) return null; + + const data = doc.data(); + if (!data) return null; + + return this.normalizeData(doc.id, data); + } catch (e) { + console.error("Error fetching document:", e); + return null; + } + } + + /** + * Queries documents by name with pagination support. + * + * @param name - The name to search for. + * @param options - Pagination options including limit and pageToken. + * @returns A promise that resolves to a paginated result of matching documents. + */ + async queryByName( + name: string, + options?: PaginationOptions, + ): Promise> { + // For now, we'll implement a simple offset-based pagination + // In a real implementation, you might want to use cursor-based pagination + const limit = options?.limit || 10; // Default limit of 10 + const offset = options?.pageToken ? parseInt(options.pageToken, 10) : 0; + + // Get all documents (not ideal for large datasets) + const allQuerySnapshot = await this.db + .collection(this.collectionName) + .where("name", "==", name) + .get(); + + // Convert to array and apply pagination + const allResults: DataType[] = []; + allQuerySnapshot.forEach((doc) => { + allResults.push(this.normalizeData(doc.id, doc.data())); + }); + + // Apply pagination + const paginatedResults = allResults.slice(offset, offset + limit); + + // Determine if there's a next page + let nextPageToken: string | undefined; + if (offset + limit < allResults.length) { + nextPageToken = String(offset + limit); + } + + return { + items: paginatedResults, + nextPageToken, + totalCount: allResults.length, + }; + } + + /** + * Lists all documents with pagination support, optionally sorted. + * + * @param order - The order in which to list the documents. + * @param options - Pagination options including limit and pageToken. + * @returns A promise that resolves to a paginated result of documents. + */ + async list( + order?: DataListOrder, + options?: PaginationOptions, + ): Promise> { + // For now, we'll implement a simple offset-based pagination + // In a real implementation, you might want to use cursor-based pagination + const limit = options?.limit || 10; // Default limit of 10 + const offset = options?.pageToken ? parseInt(options.pageToken, 10) : 0; + + let query: Query | CollectionReference = this.db.collection( + this.collectionName, + ); + + if (order) { + let orderByField = "uploadedAt"; + let orderByDirection: "desc" | "asc" = "desc"; + + if (order === DataListOrder.NewestFirst) { + orderByField = "uploadedAt"; + orderByDirection = "desc"; + } else if (order === DataListOrder.DownloadsFirst) { + orderByField = "downloadCount"; + orderByDirection = "desc"; + } + + query = query.orderBy(orderByField, orderByDirection); + } + + // Get all documents (not ideal for large datasets) + const querySnapshot = await query.get(); + + // Convert to array and apply pagination + const allResults: DataType[] = []; + querySnapshot.forEach((doc) => { + allResults.push(this.normalizeData(doc.id, doc.data())); + }); + + // Apply pagination + const paginatedResults = allResults.slice(offset, offset + limit); + + // Determine if there's a next page + let nextPageToken: string | undefined; + if (offset + limit < allResults.length) { + nextPageToken = String(offset + limit); + } + + return { + items: paginatedResults, + nextPageToken, + totalCount: allResults.length, + }; + } + + /** + * Adds a new document to the collection. + * + * @param item - The item to add (excluding the ID). + * @returns A promise that resolves to the added document with its generated ID. + */ + async put(item: Omit): Promise { + const docRef = await this.db.collection(this.collectionName).add(item); + // item already has correct types since it comes from app code, but normalize ensures consistency + return this.normalizeData(docRef.id, item); + } + + /** + * Deletes a document by its ID. + * + * @param id - The unique identifier of the document to delete. + * @returns A promise that resolves when the deletion is complete. + */ + async delete(id: string): Promise { + await this.db.collection(this.collectionName).doc(id).delete(); + } + + /** + * Increments the download count for a specific document. + * + * @param id - The unique identifier of the document. + * @returns A promise that resolves when the update is complete. + */ + async bumpDownloadCount(id: string): Promise { + const doc = await this.db.collection(this.collectionName).doc(id).get(); + if (!doc.exists) return; + + const data = doc.data(); + if (data) { + // Safely cast to number to avoid string concatenation "10" + 1 = "101" + const currentCount = Number(data.downloadCount || 0); + await this.db + .collection(this.collectionName) + .doc(id) + .update({ + downloadCount: currentCount + 1, + }); + } + } + + /** + * Updates an existing document. + * + * @param item - The partial data to update, must include the ID. + * @returns A promise that resolves to the updated document. + * @throws {Error} If the document is not found after update. + */ + async update( + item: Partial & { id: DataType["id"] }, + ): Promise { + const { id, ...rest } = item; + await this.db.collection(this.collectionName).doc(id).update(rest); + + const updatedDoc = await this.get(id); + if (!updatedDoc) { + throw new Error( + `Failed to retrieve updated document with ID ${id}`, + ); + } + return updatedDoc; + } +} diff --git a/backend/src/adapters/vendor/InMemoryBlob.ts b/backend/src/adapters/vendor/InMemoryBlob.ts index 0a5b6917..0a29973a 100644 --- a/backend/src/adapters/vendor/InMemoryBlob.ts +++ b/backend/src/adapters/vendor/InMemoryBlob.ts @@ -1,5 +1,5 @@ -import { DBEnv } from "@/adapters/client"; -import { BaseBlobStorageClient } from "@/adapters/StorageClientBase"; +import type { BaseBlobStorageClient } from "@/adapters/StorageClientBase"; +import type { VendorSecretEnv } from "@/environmentTypes"; /** * Simple in-memory blob storage used for tests/dev. @@ -11,7 +11,8 @@ export default class InMemoryBlob implements BaseBlobStorageClient { { data: Uint8Array; contentType?: string } >(); - constructor(_env?: DBEnv) { + // biome-ignore lint/complexity/noUselessConstructor: Keep compatible signature + constructor(_env?: VendorSecretEnv["inmemory"]) { // In-memory client doesn't need env, but keep signature compatible. } @@ -26,7 +27,7 @@ export default class InMemoryBlob implements BaseBlobStorageClient { const chunks: string[] = []; for (let i = 0; i < bytes.length; i += chunkSize) { chunks.push( - String.fromCharCode(...bytes.subarray(i, i + chunkSize)) + String.fromCharCode(...bytes.subarray(i, i + chunkSize)), ); } return btoa(chunks.join("")); @@ -34,7 +35,7 @@ export default class InMemoryBlob implements BaseBlobStorageClient { async upload( buffer: ArrayBuffer | Uint8Array, - contentType?: string + contentType?: string, ): Promise { const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; diff --git a/backend/src/adapters/vendor/InMemoryDB.ts b/backend/src/adapters/vendor/InMemoryDB.ts index 40e360b9..51477a68 100644 --- a/backend/src/adapters/vendor/InMemoryDB.ts +++ b/backend/src/adapters/vendor/InMemoryDB.ts @@ -1,6 +1,10 @@ -import { DataType } from "@/schema"; -import { DBEnv } from "@/adapters/client"; -import { BaseDataDBClient, DataListOrder } from "@/adapters/StorageClientBase"; +import type { + BaseDataDBClient, + PaginationOptions, + PaginationResult, +} from "@/adapters/StorageClientBase"; +import { DataListOrder } from "@/adapters/StorageClientBase"; +import type { DataType } from "@/schema"; /**{ * Simple in-memory implementation of BaseDataDBClient for tests and local usage. @@ -13,15 +17,16 @@ export default class InMemoryDataDBClient implements BaseDataDBClient { private store: Map = new Map(); private counter = 0; - constructor(env: DBEnv) {} + // biome-ignore lint/complexity/noUselessConstructor: Keep compatible signature + constructor(_env?: Record) {} async bumpDownloadCount(id: string): Promise { const item = this.store.get(id); if (!item) return; - const current = (item as any).downloadCount ?? 0; + const current = item.downloadCount ?? 0; const updated = { - ...(item as any), + ...item, downloadCount: current + 1, - } as DataType; + } satisfies DataType; this.store.set(id, updated); } @@ -40,42 +45,96 @@ export default class InMemoryDataDBClient implements BaseDataDBClient { } /** - * Query items by name. If DataType doesn't have a `name` property, returns empty array. + * Query items by name with pagination support. If DataType doesn't have a `name` property, returns empty array. * Performs a case-insensitive substring match. * @param name - name to search for + * @param options - Pagination options including limit and pageToken. */ - async queryByName(name: string): Promise { - if (!name) return []; + async queryByName( + name: string, + options?: PaginationOptions, + ): Promise> { + if (!name) return { items: [] }; const q = name.toString().toLowerCase(); - const results: DataType[] = []; + const allResults: DataType[] = []; for (const item of this.store.values()) { - const maybeName = (item as any)?.name; + const maybeName = item.name; if ( typeof maybeName === "string" && maybeName.toLowerCase().includes(q) ) { - results.push(item); + allResults.push(item); } } - return results; + + // Apply pagination + const limit = options?.limit || 10; // Default limit of 10 + const offset = options?.pageToken ? parseInt(options.pageToken, 10) : 0; + const paginatedResults = allResults.slice(offset, offset + limit); + + // Determine if there's a next page + let nextPageToken: string | undefined; + if (offset + limit < allResults.length) { + nextPageToken = String(offset + limit); + } + + return { + items: paginatedResults, + nextPageToken, + totalCount: allResults.length, + }; } /** - * List all items. If a simple "asc" / "desc" string is provided as order, it will reverse the array for "desc". + * List all items with pagination support. If a simple "asc" / "desc" string is provided as order, it will reverse the array for "desc". * @param order - optional ordering hint (best-effort) + * @param options - Pagination options including limit and pageToken. */ - async list(order?: DataListOrder): Promise { - const items = Array.from(this.store.values()); + async list( + order?: DataListOrder, + options?: PaginationOptions, + ): Promise> { + let items = Array.from(this.store.values()); + + // Apply ordering + if (order) { + if (order === DataListOrder.NewestFirst) { + items = items.sort( + (a, b) => (b.uploadedAt || 0) - (a.uploadedAt || 0), + ); + } else if (order === DataListOrder.DownloadsFirst) { + items = items.sort( + (a, b) => (b.downloadCount || 0) - (a.downloadCount || 0), + ); + } + } + try { + // biome-ignore lint/suspicious/noExplicitAny: Handle potential string input const ord = order as any; if (typeof ord === "string") { - if (ord.toLowerCase() === "desc") return items.reverse(); - return items; + if (ord.toLowerCase() === "desc") items = items.reverse(); } } catch { // ignore and return as-is } - return items; + + // Apply pagination + const limit = options?.limit || 10; // Default limit of 10 + const offset = options?.pageToken ? parseInt(options.pageToken, 10) : 0; + const paginatedResults = items.slice(offset, offset + limit); + + // Determine if there's a next page + let nextPageToken: string | undefined; + if (offset + limit < items.length) { + nextPageToken = String(offset + limit); + } + + return { + items: paginatedResults, + nextPageToken, + totalCount: items.length, + }; } /** @@ -85,6 +144,7 @@ export default class InMemoryDataDBClient implements BaseDataDBClient { */ async put(item: Omit): Promise { const id = this.generateId(); + // biome-ignore lint/suspicious/noExplicitAny: casting to DataType const newItem = { ...(item as any), id } as DataType; this.store.set(id, newItem); return newItem; @@ -98,11 +158,12 @@ export default class InMemoryDataDBClient implements BaseDataDBClient { this.store.delete(id); } async update( - item: Partial & { id: DataType["id"] } + item: Partial & { id: DataType["id"] }, ): Promise { if (!item?.id) throw new Error("Missing id for update"); if (!this.store.has(item.id)) throw new Error("Item not found"); this.store.set(item.id, { + // biome-ignore lint/suspicious/noExplicitAny: merging ...(this.store.get(item.id) as any), ...item, }); diff --git a/backend/src/adapters/vendor/S3CompatibleBlob.ts b/backend/src/adapters/vendor/S3CompatibleBlob.ts index 789b337f..74afc88c 100644 --- a/backend/src/adapters/vendor/S3CompatibleBlob.ts +++ b/backend/src/adapters/vendor/S3CompatibleBlob.ts @@ -1,6 +1,6 @@ -import { DBEnv } from "@/adapters/client"; -import { BaseBlobStorageClient } from "@/adapters/StorageClientBase"; import { AwsClient } from "aws4fetch"; +import type { DBEnv } from "@/adapters/client"; +import type { BaseBlobStorageClient } from "@/adapters/StorageClientBase"; export default class S3BlobStorageClient implements BaseBlobStorageClient { client: AwsClient; @@ -25,7 +25,7 @@ export default class S3BlobStorageClient implements BaseBlobStorageClient { } async upload( buffer: ArrayBuffer | Uint8Array, - contentType?: string + contentType?: string, ): Promise { const key = `${Date.now()}-${crypto.randomUUID()}`; const url = `https://${this.bucket}.${this.endpoint}/${key}`; @@ -44,7 +44,7 @@ export default class S3BlobStorageClient implements BaseBlobStorageClient { }); if (!res.ok) { throw new Error( - `Failed to upload to S3: ${res.status} ${res.statusText}` + `Failed to upload to S3: ${res.status} ${res.statusText}`, ); } return key; // Return only the key as the storage identifier @@ -60,12 +60,12 @@ export default class S3BlobStorageClient implements BaseBlobStorageClient { } if (!res.ok) { throw new Error( - `Failed to get from S3: ${res.status} ${res.statusText}` + `Failed to get from S3: ${res.status} ${res.statusText}`, ); } // Return a presigned URL valid for 6 minutes return this.generatePresignedUrl( - `${this.bucket}.${this.endpoint}/${url}` + `${this.bucket}.${this.endpoint}/${url}`, ); } async delete(url: string): Promise { @@ -75,7 +75,7 @@ export default class S3BlobStorageClient implements BaseBlobStorageClient { }); if (!res.ok) { throw new Error( - `Failed to delete from S3: ${res.status} ${res.statusText}` + `Failed to delete from S3: ${res.status} ${res.statusText}`, ); } } @@ -95,7 +95,7 @@ export default class S3BlobStorageClient implements BaseBlobStorageClient { // Do not include conditional headers; they can cause presigned URLs // to fail with 412 Precondition Failed if object was modified. headers: {}, - } + }, ); return signed.url; } diff --git a/backend/src/types.ts b/backend/src/environmentTypes.ts similarity index 69% rename from backend/src/types.ts rename to backend/src/environmentTypes.ts index bf2cd1a6..5954597c 100644 --- a/backend/src/types.ts +++ b/backend/src/environmentTypes.ts @@ -4,11 +4,16 @@ * @see {@link src/schema.ts} */ -import { Context } from "hono"; -import { Bindings } from "@/platform"; +import type { Context } from "hono"; import z from "zod"; +import type { Bindings } from "@/platform"; +/** + * Utility type that allows either all properties of T or none of them. + * This is useful for defining optional groups of related properties. + */ type AllOrNothing = T | { [K in keyof T]?: never }; + type AzureCosmosDBSecretEnv = AllOrNothing<{ readonly SECRET_AZURE_COSMOSDB_CONNECTION_STRING: string; readonly SECRET_AZURE_COSMOSDB_DATABASE_NAME: string; @@ -22,6 +27,14 @@ type S3SecretEnv = AllOrNothing<{ readonly SECRET_S3_REGION: string; readonly SECRET_S3_ENDPOINT: string; }>; + +type FirebaseFirestoreSecretEnv = AllOrNothing<{ + readonly SECRET_FIREBASE_PROJECT_ID: string; + readonly SECRET_FIREBASE_CLIENT_EMAIL: string; + readonly SECRET_FIREBASE_PRIVATE_KEY: string; + + readonly SECRET_FIRESTORE_COLLECTION_NAME?: string; +}>; /** * Environment secrets required for the application. * These should be provided via platform-specific secret management, not environment variables. @@ -29,7 +42,8 @@ type S3SecretEnv = AllOrNothing<{ export type RuntimeSecret = { readonly SECRET_CLERK_SECRET_KEY: string; } & S3SecretEnv & - AzureCosmosDBSecretEnv; + AzureCosmosDBSecretEnv & + FirebaseFirestoreSecretEnv; /** * Environment variables required for the application. * These can be provided via environment variables or platform-specific configuration. @@ -70,3 +84,15 @@ type AuthenticatedContext = { export type AuthenticatedBindings = Bindings & { Variables: AuthenticatedContext["Variables"]; }; +/** + * Vendor-specific secret environment variables. + * @example + * function something(env: VendorSecretEnv['azure']) { ... } + */ +export type VendorSecretEnv = { + azure: AzureCosmosDBSecretEnv; + s3: S3SecretEnv; + firebase: FirebaseFirestoreSecretEnv; + // Something exists, but not typed. So we use an empty object type. + [Key: string]: Record; +}; diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 6500ffb4..880a5819 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -1,6 +1,6 @@ -import { Hono } from "hono"; -import { AuthenticatedBindings, UserType } from "@/types"; import { clerkMiddleware, getAuth } from "@hono/clerk-auth"; +import { Hono } from "hono"; +import type { AuthenticatedBindings, UserType } from "@/environmentTypes"; const authLevels = ["admin", "trusted", "known", "visitor"] as const; /** diff --git a/backend/src/lib/cors.ts b/backend/src/lib/cors.ts index 6ead0875..4f662fc3 100644 --- a/backend/src/lib/cors.ts +++ b/backend/src/lib/cors.ts @@ -1,4 +1,4 @@ -import { type Context, Hono, type Next } from "hono"; +import type { Context, Next } from "hono"; import { cors as corsHono } from "hono/cors"; /** @@ -26,7 +26,7 @@ const allowedOrigins = [ */ export async function cors(c: Ctx, next: Next) { return corsHono({ - origin: allowedOrigins.flatMap((i) => ["http://" + i, "https://" + i]), + origin: allowedOrigins.flatMap((i) => [`http://${i}`, `https://${i}`]), credentials: true, allowMethods: "GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS".split(","), })(c, next); diff --git a/backend/src/main.ts b/backend/src/main.ts index 499c6a32..eb13beca 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,26 +1,45 @@ -import { Hono } from "hono"; -import { Bindings } from "@/platform"; -import dataRoutes from "@/routes/data"; import { Scalar } from "@scalar/hono-api-reference"; -import { openAPIRouteHandler } from "hono-openapi"; -import { cors } from "@/lib/cors"; +import { Hono } from "hono"; import { logger } from "hono/logger"; +import { describeRoute, openAPIRouteHandler } from "hono-openapi"; +import { cors } from "@/lib/cors"; +import type { Bindings } from "@/platform"; +import blobRoutes from "@/routes/blob"; +import dataRoutes from "@/routes/data"; await import("consola") .then((i) => { //use consola for logging if available + // biome-ignore lint/suspicious/noGlobalAssign: Enhance console with consola console = { ...i.default, ...console }; }) .catch(); // Ignore errors (e.g., production build) let app = new Hono(); - app = app.use(logger(), cors); -app = app.get("/", (c) => c.text("Oh hi")); +app = app.get( + "/", + describeRoute({ + summary: "Root endpoint", + description: + "Returns a friendly greeting. Can be used as a health check.", + tags: ["General"], + responses: { + 200: { + description: "Greeting message", + content: { + "text/plain": { schema: { type: "string" } }, + }, + }, + }, + }), + (c) => c.text("Oh hi"), +); // Mount API routes under /api app = app.route("/api", dataRoutes); +app = app.route("/api", blobRoutes); const jwtAuthScheme = Object.entries({ ClerkUser: @@ -56,13 +75,13 @@ app = app.get( "API for managing Data items in ArisuTalk Phonebook", }, }, - }) + }), ); app = app.get( "/docs", Scalar({ title: "ArisuTalk Phonebook API", url: "/openapi.json", - }) + }), ); export default app; diff --git a/backend/src/platform.ts b/backend/src/platform.ts index 4d2a6cdc..653389a6 100644 --- a/backend/src/platform.ts +++ b/backend/src/platform.ts @@ -4,7 +4,7 @@ * Platform-specific code should only be included here. */ -import { RuntimeSecret, RuntimeVariable } from "@/types"; +import type { RuntimeSecret, RuntimeVariable } from "@/environmentTypes"; export type Bindings = { Bindings: RuntimeSecret & RuntimeVariable; diff --git a/backend/src/routes/blob.ts b/backend/src/routes/blob.ts new file mode 100644 index 00000000..3e13ea50 --- /dev/null +++ b/backend/src/routes/blob.ts @@ -0,0 +1,129 @@ +import { describeRoute, validator } from "hono-openapi"; +import z from "zod"; +import { BlobClient, DataDBClient } from "@/adapters/client"; +import { createAuthedHonoRouter } from "@/lib/auth"; +import { DataSchema } from "@/schema"; + +const IdParamSchema = z.object({ id: z.string().min(1) }); + +const router = createAuthedHonoRouter("known") + // Upload blob + // Accepts binary body and returns a blob URL stored in the storage client. + .post( + "/data/:id/blob", + describeRoute({ + summary: "Upload a blob for a Data item", + description: + "Uploads a binary blob and associates it with the Data item. It can be used to overwrite existing blobs.", + tags: ["Blob"], + responses: { + 200: { + description: "Blob uploaded successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + url: { type: "string", format: "url" }, + }, + }, + }, + }, + }, + 400: { description: "Invalid ID or request" }, + 404: { description: "Data item not found" }, + 500: { description: "Blob upload failed" }, + }, + security: [{ ClerkUser: [] }], + }), + validator("param", IdParamSchema), + async (c) => { + const id = c.req.param("id"); + const idOk = IdParamSchema.safeParse({ id }); + if (!idOk.success) return c.json({ error: "invalid id" }, 400); + + const db = await DataDBClient(c.env); + const existing = await db.get(id); + if (!existing) return c.json({ error: "not found" }, 404); + + const validatedItem = DataSchema.parse(existing); + const user = c.get("user"); + // Only admin or author can upload blob + if ( + user.role !== "admin" && + user.authUid !== validatedItem.author + ) { + return c.text("Forbidden", 403); + } + + const storage = await BlobClient(c.env); + const ab = await c.req.arrayBuffer(); + const contentType = + c.req.header("content-type") ?? "application/octet-stream"; + const url = await storage.upload(ab, contentType); + + existing.additionalData = url; + const validated = DataSchema.parse(existing); + await db.update({ + id: validated.id, + additionalData: validated.additionalData, + }); + + return c.json({ url }); + }, + ) + + .get( + "/data/:id/blob", + describeRoute({ + summary: "Get the blob URL for a Data item", + description: + "Retrieves the blob URL associated with the Data item. Increases download count if applicable.", + tags: ["Blob"], + responses: { + 307: { + description: "Redirect to the blob URL.", + }, + 400: { description: "Invalid ID" }, + 404: { description: "ID found, but Blob not found" }, + }, + security: [{ ClerkUser: [] }], + parameters: [ + { + name: "id", + in: "path", + description: "The ID of the Data item", + required: true, + schema: { type: "string" }, + }, + ], + }), + validator("param", IdParamSchema), + async (c) => { + const id = c.req.param("id"); + const idOk = IdParamSchema.safeParse({ id }); + if (!idOk.success) return c.json({ error: "invalid id" }, 400); + + const db = await DataDBClient(c.env); + const storage = await BlobClient(c.env); + + const existing = await db.get(id); + if (!existing) return c.json({ error: "not found" }, 404); + + const validated = DataSchema.parse(existing); + + if (!validated.additionalData) { + return c.json({ error: "blob not found" }, 404); + } + const url = await storage.get(validated.additionalData); + if (!url) { + return c.json({ error: "blob not found" }, 404); + } + + await db.bumpDownloadCount(id); + + return c.redirect(url, 307); + }, + ); + +export default router; diff --git a/backend/src/routes/data.ts b/backend/src/routes/data.ts index cb930066..9e791281 100644 --- a/backend/src/routes/data.ts +++ b/backend/src/routes/data.ts @@ -1,395 +1,342 @@ -import z from "zod"; -import { DataDBClient, BlobClient } from "@/adapters/client"; -import { DataType, DataSchema, PartialDataSchema, PartialData } from "@/schema"; import { describeRoute, resolver, validator } from "hono-openapi"; +import z from "zod"; +import { BlobClient, DataDBClient } from "@/adapters/client"; +import { + DataListOrder, + type PaginationOptions, +} from "@/adapters/StorageClientBase"; import { createAuthedHonoRouter } from "@/lib/auth"; -import { DataListOrder } from "@/adapters/StorageClientBase"; +import { + DataSchema, + type DataType, + type PartialData, + PartialDataSchema, +} from "@/schema"; -let router = createAuthedHonoRouter("known"); // Schemas const IdParamSchema = z.object({ id: z.string().min(1) }); const UpdateDataSchema = PartialDataSchema.partial(); -const QueryNameSchema = z.object({ name: z.string().min(1).optional() }); - -// Check availability of user perm -router = router.get( - "/check", - describeRoute({ - summary: "Check if the user has permission to create Data items", - description: - "Checks if the authenticated user has permission to read/create Data items.", - tags: ["Data"], - security: [{ ClerkUser: [] }], - responses: { - 204: { - description: "User has permission. No content returned.", - }, - 403: { description: "Forbidden" }, - 401: { description: "Unauthorized" }, - }, - }), - async (c) => { - // If we reach here, the user is authenticated - return c.status(204); // No Content - } -); -// Create -router = router.post( - "/data", - describeRoute({ - summary: "Create a new Data item", - description: "Creates a new Data item in the database.", - tags: ["Data"], - requestBody: { - content: { - "application/json": {}, +const QueryNameSchema = z.object({ + name: z.string().min(1).optional(), + limit: z.coerce.number().int().positive().max(100).optional().default(10), + pageToken: z.string().optional(), +}); + +const router = createAuthedHonoRouter("known") + // Check availability of user perm + .get( + "/check", + describeRoute({ + summary: "Check if the user has permission to create Data items", + description: + "Checks if the authenticated user has permission to read/create Data items.", + tags: ["Data"], + security: [{ ClerkUser: [] }], + responses: { + 204: { + description: "User has permission. No content returned.", + }, + 403: { description: "Forbidden" }, + 401: { description: "Unauthorized" }, }, - required: true, + }), + async (c) => { + // If we reach here, the user is authenticated + return c.body(null, 204); // No Content }, - security: [{ ClerkUser: [] }], - responses: { - 201: { - description: "Data item created successfully", + ) + // Create + .post( + "/data", + describeRoute({ + summary: "Create a new Data item", + description: "Creates a new Data item in the database.", + tags: ["Data"], + requestBody: { content: { - "application/json": { schema: resolver(DataSchema) }, + "application/json": {}, }, - }, - 400: { description: "Invalid request body" }, - 403: { description: "Forbidden" }, - }, - }), - validator("json", PartialDataSchema), - - //Authorization - async (c, next) => { - if (c.get("user").role !== "admin") { - return c.text("Forbidden!", 403); - } - return next(); - }, - async (c) => { - const user = c.get("user"); - const body = c.req.valid("json"); - - const item: PartialData = { - name: body.name, - description: body.description, - author: user.authUid, - additionalData: body.additionalData, - downloadCount: 0, - encrypted: body.encrypted, - uploadedAt: Date.now(), - }; - - // Validate with canonical DataSchema before persisting - const validated = PartialDataSchema.parse(item); - - const db = await DataDBClient(c.env); - return c.json(await db.put(validated), 201); - } -); - -// Read list / query -router = router.get( - "/data", - describeRoute({ - summary: "List or query Data items", - description: "Lists all Data items or queries by name.", - tags: ["Data"], - parameters: [ - { - name: "name", - in: "query", - description: "Optional name to filter results", - required: false, - schema: { type: "string" }, - }, - ], - }), - validator("query", QueryNameSchema), - async (c) => { - // validate optional query param 'name' - const q = c.req.query("name")?.trim(); - const maybe = QueryNameSchema.safeParse({ name: q }); - if (!maybe.success) { - return c.json({ error: maybe.error.message }, 400); - } - - const db = await DataDBClient(c.env); - if (q) { - const results = await db.queryByName(q); - // validate each result - const validated = results.map((r: any) => DataSchema.parse(r)); - return c.json(validated); - } else { - const all = await db.list(DataListOrder.Undefined); - const validatedAll = all.map((r: any) => DataSchema.parse(r)); - return c.json(validatedAll); - } - } -); - -// Read single -router = router.get( - "/data/:id", - describeRoute({ - summary: "Get a single Data item by ID", - description: "Retrieves a single Data item by its ID.", - tags: ["Data"], - parameters: [ - { - name: "id", - in: "path", - description: "The ID of the Data item", required: true, - schema: { type: "string" }, }, - ], - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { schema: resolver(DataSchema) }, + security: [{ ClerkUser: [] }], + responses: { + 201: { + description: "Data item created successfully", + content: { + "application/json": { schema: resolver(DataSchema) }, + }, }, + 400: { description: "Invalid request body" }, + 403: { description: "Forbidden" }, }, - 400: { description: "Invalid ID supplied" }, - 404: { description: "Data item not found" }, + }), + validator("json", PartialDataSchema), + async (c) => { + const user = c.get("user"); + const body = c.req.valid("json"); + + const item: PartialData = { + name: body.name, + description: body.description, + author: user.authUid, + additionalData: body.additionalData, + downloadCount: 0, + encrypted: body.encrypted, + uploadedAt: Date.now(), + }; + + // Validate with canonical DataSchema before persisting + const validated = PartialDataSchema.parse(item); + + const db = await DataDBClient(c.env); + return c.json(await db.put(validated), 201); }, - }), - async (c) => { - const id = c.req.param("id"); - const parsed = IdParamSchema.safeParse({ id }); - if (!parsed.success) return c.text("Invalid ID", 400); - - const db = await DataDBClient(c.env); - const item = await db.get(id); - if (!item) return c.text("Not Found", 404); - - const validated = DataSchema.parse(item); - return c.json(validated); - } -); - -// Update -router.patch( - "/data/:id", - describeRoute({ - summary: "Update a Data item by ID", - description: "Updates a Data item by its ID.", - tags: ["Data"], - security: [{ ClerkUser: [] }], - responses: { - 200: { - description: "Data item updated successfully", - content: { - "application/json": { schema: resolver(DataSchema) }, + ) + + // Read list / query + .get( + "/data", + describeRoute({ + summary: "List or query Data items with pagination", + description: + "Lists all Data items or queries by name with pagination support.", + tags: ["Data"], + security: [{ ClerkUser: [] }], + parameters: [ + { + name: "name", + in: "query", + description: "Optional name to filter results", + required: false, + schema: { type: "string" }, }, - }, - 400: { description: "Invalid ID or request body" }, - 404: { description: "Data item not found" }, - 403: { description: "Forbidden" }, - }, - }), - validator("json", UpdateDataSchema), - async (c) => { - const id = c.req.param("id"); - const idOk = IdParamSchema.safeParse({ id }); - if (!idOk.success) return c.json({ error: "invalid id" }, 400); - - const body = c.req.valid("json"); - const db = await DataDBClient(c.env); - - const existing = await db.get(id); - if (!existing) return c.json({ error: "not found" }, 404); - - // Validate stored item before authorization check - const stored = DataSchema.parse(existing); - - // Only the author may modify the item - const user = c.get("user"); - if (user.authUid !== stored.author && user.role !== "admin") { - return c.text("Forbidden", 403); - } - - const updated: DataType = { - id: stored.id, - author: stored.author, - name: body.name ?? stored.name, - description: body.description ?? stored.description, - additionalData: body.additionalData ?? stored.additionalData, - encrypted: body.encrypted ?? stored.encrypted, - downloadCount: body.downloadCount ?? stored.downloadCount, - uploadedAt: Date.now(), - }; - - const validated = DataSchema.parse(updated); - // Use update() for existing records to avoid creating duplicates - - await db.update(validated); - return c.json(validated); - } -); - -// Delete -router = router.delete( - "/data/:id", - describeRoute({ - summary: "Delete a Data item by ID", - description: "Deletes a Data item by its ID.", - tags: ["Data"], - responses: { - 200: { description: "Data item deleted successfully" }, - 400: { description: "Invalid ID" }, - 404: { description: "Data item not found" }, - 403: { description: "Forbidden" }, - }, - security: [{ ClerkAdmin: [] }], - }), - validator("param", IdParamSchema), - async (c) => { - // Only admin can delete - const user = c.get("user"); - if (user.role !== "admin") { - return c.text("Forbidden", 403); - } - const id = c.req.param("id"); - const idOk = IdParamSchema.safeParse({ id }); - if (!idOk.success) return c.json({ error: "invalid id" }, 400); - - const db = await DataDBClient(c.env); - const existing = await db.get(id); - if (!existing) return c.json({ error: "not found" }, 404); - - // validate stored item before acting - const validated = DataSchema.parse(existing); + { + name: "limit", + in: "query", + description: + "Maximum number of items to return (max 100, default 10)", + required: false, + schema: { + type: "integer", + minimum: 1, + maximum: 100, + default: 10, + }, + }, + { + name: "pageToken", + in: "query", + description: + "Token for retrieving the next page of results", + required: false, + schema: { type: "string" }, + }, + ], + }), + validator("query", QueryNameSchema), + async (c) => { + // validate optional query params + const q = c.req.query("name")?.trim(); + const limit = parseInt(c.req.query("limit") || "10", 10); + const pageToken = c.req.query("pageToken"); + + const maybe = QueryNameSchema.safeParse({ + name: q, + limit, + pageToken, + }); + if (!maybe.success) { + return c.json({ error: maybe.error.message }, 400); + } - // If additionalData points to a blob URL stored in storage, attempt delete - const storage = await BlobClient(c.env); - if (validated.additionalData) { - try { - await storage.delete(validated.additionalData); - } catch (e) { - // Log deletion failure to help troubleshoot orphaned blobs - console.error( - `Failed to delete blob ${validated.additionalData} for data item ${id}:`, - e + const paginationOptions: PaginationOptions = { + limit: maybe.data.limit, + pageToken: maybe.data.pageToken, + }; + + const db = await DataDBClient(c.env); + if (q) { + const results = await db.queryByName(q, paginationOptions); + // validate each result + const validatedItems = results.items.map((r) => + DataSchema.parse(r), + ); + return c.json({ + items: validatedItems, + nextPageToken: results.nextPageToken, + totalCount: results.totalCount, + }); + } else { + const all = await db.list( + DataListOrder.Undefined, + paginationOptions, + ); + const validatedItems = all.items.map((r) => + DataSchema.parse(r), ); + return c.json({ + items: validatedItems, + nextPageToken: all.nextPageToken, + totalCount: all.totalCount, + }); } - } - - await db.delete(id); - return c.json({ ok: true }); - } -); - -// Upload blob -// Accepts binary body and returns a blob URL stored in the storage client. -router = router.post( - "/data/:id/blob", - describeRoute({ - summary: "Upload a blob for a Data item", - description: - "Uploads a binary blob and associates it with the Data item. It can be used to overwrite existing blobs.", - tags: ["Blob"], - responses: { - 200: { - description: "Blob uploaded successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - url: { type: "string", format: "url" }, - }, - }, + }, + ) + + // Read single + .get( + "/data/:id", + describeRoute({ + summary: "Get a single Data item by ID", + description: "Retrieves a single Data item by its ID.", + tags: ["Data"], + security: [{ ClerkUser: [] }], + parameters: [ + { + name: "id", + in: "path", + description: "The ID of the Data item", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { schema: resolver(DataSchema) }, }, }, + 400: { description: "Invalid ID supplied" }, + 404: { description: "Data item not found" }, }, - 400: { description: "Invalid ID or request" }, - 404: { description: "Data item not found" }, - 500: { description: "Blob upload failed" }, - }, - security: [{ ClerkUser: [] }], - }), - validator("param", IdParamSchema), - async (c) => { - const id = c.req.param("id"); - const idOk = IdParamSchema.safeParse({ id }); - if (!idOk.success) return c.json({ error: "invalid id" }, 400); - - const db = await DataDBClient(c.env); - const existing = await db.get(id); - if (!existing) return c.json({ error: "not found" }, 404); - - const storage = await BlobClient(c.env); - const ab = await c.req.arrayBuffer(); - const contentType = - c.req.header("content-type") ?? "application/octet-stream"; - const url = await storage.upload(ab, contentType); + }), + async (c) => { + const id = c.req.param("id"); + const parsed = IdParamSchema.safeParse({ id }); + if (!parsed.success) return c.text("Invalid ID", 400); - existing.additionalData = url; - const validated = DataSchema.parse(existing); - await db.update({ - id: validated.id, - additionalData: validated.additionalData, - }); + const db = await DataDBClient(c.env); + const item = await db.get(id); + if (!item) return c.text("Not Found", 404); - return c.json({ url }); - } -); - -router = router.get( - "/data/:id/blob", - describeRoute({ - summary: "Get the blob URL for a Data item", - description: - "Retrieves the blob URL associated with the Data item. Increases download count if applicable.", - tags: ["Blob"], - responses: { - 307: { - description: "Redirect to the blob URL.", - }, - 400: { description: "Invalid ID" }, - 404: { description: "ID found, but Blob not found" }, + const validated = DataSchema.parse(item); + return c.json(validated); }, - security: [{ ClerkUser: [] }], - parameters: [ - { - name: "id", - in: "path", - description: "The ID of the Data item", - required: true, - schema: { type: "string" }, + ) + + // Update + .patch( + "/data/:id", + describeRoute({ + summary: "Update a Data item by ID", + description: "Updates a Data item by its ID.", + tags: ["Data"], + security: [{ ClerkUser: [] }], + responses: { + 200: { + description: "Data item updated successfully", + content: { + "application/json": { schema: resolver(DataSchema) }, + }, + }, + 400: { description: "Invalid ID or request body" }, + 404: { description: "Data item not found" }, + 403: { description: "Forbidden" }, }, - ], - }), - validator("param", IdParamSchema), - async (c) => { - const id = c.req.param("id"); - const idOk = IdParamSchema.safeParse({ id }); - if (!idOk.success) return c.json({ error: "invalid id" }, 400); - - const db = await DataDBClient(c.env); - const storage = await BlobClient(c.env); - - const existing = await db.get(id); - if (!existing) return c.json({ error: "not found" }, 404); - - const validated = DataSchema.parse(existing); + }), + validator("json", UpdateDataSchema), + async (c) => { + const id = c.req.param("id"); + const idOk = IdParamSchema.safeParse({ id }); + if (!idOk.success) return c.json({ error: "invalid id" }, 400); + + const body = c.req.valid("json"); + const db = await DataDBClient(c.env); + + const existing = await db.get(id); + if (!existing) return c.json({ error: "not found" }, 404); + + // Validate stored item before authorization check + const stored = DataSchema.parse(existing); + + // Only the author may modify the item + const user = c.get("user"); + if (user.authUid !== stored.author && user.role !== "admin") { + return c.text("Forbidden", 403); + } - if (!validated.additionalData) { - return c.json({ error: "blob not found" }, 404); - } - const url = await storage.get(validated.additionalData); - if (!url) { - return c.json({ error: "blob not found" }, 404); - } + const updated: DataType = { + id: stored.id, + author: stored.author, + name: body.name ?? stored.name, + description: body.description ?? stored.description, + additionalData: body.additionalData ?? stored.additionalData, + encrypted: body.encrypted ?? stored.encrypted, + downloadCount: body.downloadCount ?? stored.downloadCount, + uploadedAt: Date.now(), + }; + + const validated = DataSchema.parse(updated); + // Use update() for existing records to avoid creating duplicates + + await db.update(validated); + return c.json(validated); + }, + ) + + // Delete + .delete( + "/data/:id", + describeRoute({ + summary: "Delete a Data item by ID", + description: "Deletes a Data item by its ID.", + tags: ["Data"], + responses: { + 200: { description: "Data item deleted successfully" }, + 400: { description: "Invalid ID" }, + 404: { description: "Data item not found" }, + 403: { description: "Forbidden" }, + }, + security: [{ ClerkAdmin: [] }], + }), + validator("param", IdParamSchema), + async (c) => { + const user = c.get("user"); + const id = c.req.param("id"); + const idOk = IdParamSchema.safeParse({ id }); + if (!idOk.success) return c.json({ error: "invalid id" }, 400); + + const db = await DataDBClient(c.env); + const existing = await db.get(id); + if (!existing) return c.json({ error: "not found" }, 404); + + // validate stored item before acting + const validated = DataSchema.parse(existing); + + // Only admin or author can delete + if (user.role !== "admin" && user.authUid !== validated.author) { + return c.text("Forbidden", 403); + } - await db.bumpDownloadCount(id); + // If additionalData points to a blob URL stored in storage, attempt delete + const storage = await BlobClient(c.env); + if (validated.additionalData) { + try { + await storage.delete(validated.additionalData); + } catch (e) { + // Log deletion failure to help troubleshoot orphaned blobs + console.error( + `Failed to delete blob ${validated.additionalData} for data item ${id}:`, + e, + ); + } + } - return c.redirect(url, 307); - } -); + await db.delete(id); + return c.json({ ok: true }); + }, + ); export default router; diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 0a2c45b5..f448b272 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -15,7 +15,7 @@ export const Queryable = z.object({ id: z .string() .describe( - "Unique identifier for the data entry. It is used as internal reference." + "Unique identifier for the data entry. It is used as internal reference.", ), }); /** @@ -60,7 +60,7 @@ export const PartialDataSchema = z.object({ .default(false) .describe( "Whether the data is encrypted. If true, the additionalData(not link, but content) is encrypted. " + - "Encryption implies that it is private data." + "Encryption implies that it is private data.", ), /** * URL to the additional data blob stored in the storage client. @@ -71,7 +71,7 @@ export const PartialDataSchema = z.object({ additionalData: z .url() .describe( - "URL to the additional data blob. Its content may be encrypted. It might not be publicly accessible, use dedicated endpoint to access it." + "URL to the additional data blob. Its content may be encrypted. It might not be publicly accessible, use dedicated endpoint to access it.", ), /** * @property Number of times the content has been downloaded. @@ -83,7 +83,7 @@ export const PartialDataSchema = z.object({ .min(0) .default(0) .describe( - "Number of times the data has been downloaded. It is not guaranteed to be accurate." + "Number of times the data has been downloaded. It is not guaranteed to be accurate.", ), uploadedAt: z .number() @@ -93,4 +93,44 @@ export const PartialDataSchema = z.object({ export const DataSchema = PartialDataSchema.and(Queryable); +export const PaginationOptionschema = z.object({ + limit: z.coerce + .number() + .int() + .positive() + .max(100) + .optional() + .default(10) + .describe("Number of items to return per page."), + pageToken: z + .string() + .optional() + .describe( + "Token for retrieving the next page of results. Is typically returned from a previous query.", + ), +}); +export const PaginatedResultSchema = z.object({ + items: z.array(DataSchema).describe("Array of items for the current page."), + nextPageToken: z + .string() + .optional() + .describe( + "Token for retrieving the next page of results. If null or undefined, there are no more pages. It is typically(not nececcary) alphanumeric value.", + ), + totalCount: z + .number() + .int() + .min(-1) + .default(-1) + .describe( + "Total number of items (if available). This might not be supported by all implementations. If not supported, it will be -1.", + ), +}); + +export type paginatedResultType = { + items: T[]; + nextPageToken?: string; + totalCount: number; +}; + export type PartialData = Partial> & { id?: string }; diff --git a/backend/tests/adapters/vendor/AzureCosmosDB.test.ts b/backend/tests/adapters/vendor/AzureCosmosDB.test.ts new file mode 100644 index 00000000..d5fcd125 --- /dev/null +++ b/backend/tests/adapters/vendor/AzureCosmosDB.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DBEnv } from "@/adapters/client"; + +// Prepare module-level mocks (we will wire them to the mocked module below) +const mockFetchAll = vi.fn(); +const mockQuery = vi.fn(() => ({ fetchAll: mockFetchAll })); +const mockItems = { query: mockQuery }; +const mockContainer = { items: mockItems }; +const mockDatabase = { container: vi.fn(() => mockContainer) }; +const mockCosmosClient = vi.fn(() => ({ database: () => mockDatabase })); + +// Auto-mock the module, then set the implementation for CosmosClient +vi.mock("@azure/cosmos"); + +import { CosmosClient } from "@azure/cosmos"; + +//@ts-expect-error It is a mock +CosmosClient.mockImplementation(mockCosmosClient); + +import AzureCosmosDB from "@/adapters/vendor/AzureCosmosDB"; + +const env: DBEnv = { + SECRET_AZURE_COSMOSDB_CONNECTION_STRING: "conn", + SECRET_AZURE_COSMOSDB_DATABASE_NAME: "db", + SECRET_AZURE_COSMOSDB_CONTAINER_NAME: "c", + SECRET_CLERK_SECRET_KEY: "test-clerk-key", + ENV_CLERK_PUBLIC_KEY: "test-clerk-pub", +}; + +describe("AzureCosmosDB", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("queryByName returns paginated resources and total count", async () => { + // For resource query, return one item + mockFetchAll.mockImplementationOnce(async () => ({ + resources: [ + { id: "1", name: "bob", author: "a", downloadCount: 0 }, + ], + })); + // For count query, return count + mockFetchAll.mockImplementationOnce(async () => ({ + resources: [{ count: 1 }], + })); + + const client = new AzureCosmosDB(env); + const res = await client.queryByName("bob", { + limit: 10, + pageToken: "0", + }); + expect(res.items.length).toBe(1); + expect(res.totalCount).toBe(1); + }); + + it("list returns items and totalCount", async () => { + mockFetchAll.mockImplementationOnce(async () => ({ + resources: [{ id: "1", name: "x", author: "a", downloadCount: 0 }], + })); + mockFetchAll.mockImplementationOnce(async () => ({ + resources: [{ count: 1 }], + })); + const client = new AzureCosmosDB(env); + const res = await client.list(undefined, { limit: 10, pageToken: "0" }); + expect(res.items.length).toBe(1); + expect(res.totalCount).toBe(1); + }); +}); diff --git a/backend/tests/adapters/vendor/FirebaseFirestore.integration.test.ts b/backend/tests/adapters/vendor/FirebaseFirestore.integration.test.ts new file mode 100644 index 00000000..798e6713 --- /dev/null +++ b/backend/tests/adapters/vendor/FirebaseFirestore.integration.test.ts @@ -0,0 +1,94 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import type { DBEnv } from "@/adapters/client"; +import FirebaseFirestoreClient from "@/adapters/vendor/FirebaseFirestore"; + +// Define the shape of the standard Firebase service account JSON +interface FirebaseServiceAccount { + project_id: string; + client_email: string; + private_key: string; +} + +describe.runIf(async () => { + const secretModule = await import("../../../secret.json", { + with: { type: "json" }, + }); + + const secret = (secretModule.default || + secretModule) satisfies FirebaseServiceAccount; + + if (!secret.project_id || !secret.client_email || !secret.private_key) + return false; + return true; +})("FirebaseFirestoreClient Integration (REAL)", () => { + let client: FirebaseFirestoreClient; + + beforeAll(async (ctx) => { + // Add 'this' parameter for access to Vitest context + try { + // Already validated in describe.runIf + const secret: FirebaseServiceAccount = await import( + "../../../secret.json", + { + with: { type: "json" }, + } + ); + const env: DBEnv = { + SECRET_FIREBASE_PROJECT_ID: secret.project_id, + SECRET_FIREBASE_CLIENT_EMAIL: secret.client_email, + SECRET_FIREBASE_PRIVATE_KEY: secret.private_key, + SECRET_CLERK_SECRET_KEY: "dummy", + ENV_CLERK_PUBLIC_KEY: "dummy", + }; + + client = new FirebaseFirestoreClient(env); + } catch (error) { + console.warn( + "Skipping integration tests: secret.json not found or failed to load.", + error, + ); + ctx; + } + }); + + it("should list documents (real connection)", async () => { + const docs = await client.list(); // Use non-null assertion as we've skipped if client is null + console.log(`Found ${docs.items.length} documents.`); + expect(Array.isArray(docs.items)).toBe(true); + }); + + it("should write, update, read, and delete a document (real connection)", async () => { + const newItem = { + name: `Integration Test Item ${Date.now()}`, + author: "tester", + downloadCount: 0, + encrypted: false, + additionalData: "test-data", + }; + + // 1. Create + const created = await client.put(newItem); // Use non-null assertion + expect(created.id).toBeDefined(); + expect(created.name).toBe(newItem.name); + console.log("Created document:", created.id); + + // 2. Update + const updatePayload = { id: created.id, downloadCount: 100 }; + const updated = await client.update(updatePayload); // Use non-null assertion + expect(updated.downloadCount).toBe(100); + console.log("Updated document:", updated.id); + + // 3. Get (Verify) + const fetched = await client.get(created.id); // Use non-null assertion + expect(fetched).toBeDefined(); + expect(fetched?.downloadCount).toBe(100); + + // 4. Delete + await client.delete(created.id); // Use non-null assertion + console.log("Deleted document:", created.id); + + // 5. Verify Deletion + const deleted = await client.get(created.id); // Use non-null assertion + expect(deleted).toBeNull(); + }); +}); diff --git a/backend/tests/adapters/vendor/FirebaseFirestore.test.ts b/backend/tests/adapters/vendor/FirebaseFirestore.test.ts new file mode 100644 index 00000000..d47a2210 --- /dev/null +++ b/backend/tests/adapters/vendor/FirebaseFirestore.test.ts @@ -0,0 +1,383 @@ +import { createFirestoreClient } from "firebase-rest-firestore"; +import { + beforeEach, + describe, + expect, + it, + type MockedFunction, + vi, +} from "vitest"; +import type { DBEnv } from "@/adapters/client"; +import { DataListOrder } from "@/adapters/StorageClientBase"; +import FirebaseFirestoreClient from "@/adapters/vendor/FirebaseFirestore"; +import type { DataType } from "@/schema"; + +// Mock the library +vi.mock("firebase-rest-firestore", () => ({ + createFirestoreClient: vi.fn(), +})); + +/** + * Generic record type for Firestore data. + */ +type FirestoreData = Record; + +/** + * Mock interface for a Firestore document reference. + */ +interface MockDocRef { + get: MockedFunction< + () => Promise<{ + exists: boolean; + id: string; + data: () => FirestoreData | null; + }> + >; + delete: MockedFunction<() => Promise>; + update: MockedFunction<(data: FirestoreData) => Promise>; +} + +/** + * Mock interface for a Firestore query. + */ +interface MockQuery { + get: MockedFunction< + () => Promise<{ id: string; data: () => FirestoreData }[]> + >; + where: MockedFunction<() => MockQuery>; + orderBy: MockedFunction<() => MockQuery>; +} + +/** + * Mock interface for a Firestore collection. + */ +interface MockCollection { + doc: MockedFunction<(id: string) => MockDocRef>; + add: MockedFunction<(data: FirestoreData) => Promise<{ id: string }>>; + where: MockedFunction< + (field: string, op: string, value: unknown) => MockQuery + >; + orderBy: MockedFunction<(field: string, dir: string) => MockQuery>; + get: MockedFunction< + () => Promise<{ id: string; data: () => FirestoreData }[]> + >; +} + +/** + * Mock interface for the Firestore database instance. + */ +interface MockDb { + collection: MockedFunction<(name: string) => MockCollection>; +} + +describe("FirebaseFirestoreClient", () => { + const mockEnv: DBEnv = { + SECRET_FIREBASE_PROJECT_ID: "test-project", + SECRET_FIREBASE_CLIENT_EMAIL: "test@example.com", + SECRET_FIREBASE_PRIVATE_KEY: "test-key", + SECRET_CLERK_SECRET_KEY: "test-clerk-key", // Required by DBEnv + ENV_CLERK_PUBLIC_KEY: "test-clerk-pub", // Required by DBEnv + }; + + let client: FirebaseFirestoreClient; + let mockDb: MockDb; + let mockCollection: MockCollection; + let mockDocRef: MockDocRef; + let mockQuery: MockQuery; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup chainable mocks with proper typing + mockDocRef = { + get: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + }; + + // Create mock functions first to handle circular references in types + const queryWhere = vi.fn<() => MockQuery>(); + const queryOrderBy = vi.fn<() => MockQuery>(); + + mockQuery = { + get: vi.fn(), + where: queryWhere, + orderBy: queryOrderBy, + }; + + // Setup the circular returns + queryWhere.mockReturnValue(mockQuery); + queryOrderBy.mockReturnValue(mockQuery); + + mockCollection = { + doc: vi.fn((_id: string) => mockDocRef), + add: vi.fn(async (_data: FirestoreData) => ({ id: "new-id" })), + where: vi.fn( + (_field: string, _op: string, _value: unknown) => mockQuery, + ), + orderBy: vi.fn((_field: string, _dir: string) => mockQuery), + get: vi.fn(), + }; + + mockDb = { + collection: vi.fn((_name: string) => mockCollection), + }; + + // Type the mock for createFirestoreClient + vi.mocked(createFirestoreClient).mockReturnValue( + mockDb as unknown as ReturnType, + ); + + client = new FirebaseFirestoreClient(mockEnv); + }); + + /** + * Tests initialization logic. + */ + it("should initialize correctly with environment variables", () => { + expect(createFirestoreClient).toHaveBeenCalledWith({ + projectId: "test-project", + clientEmail: "test@example.com", + privateKey: "test-key", + }); + }); + + it("should handle private key newline replacement", () => { + const envWithEscapedNewlines: DBEnv = { + ...mockEnv, + SECRET_FIREBASE_PRIVATE_KEY: "line1\nline2", + }; + new FirebaseFirestoreClient(envWithEscapedNewlines); + expect(createFirestoreClient).toHaveBeenCalledWith( + expect.objectContaining({ + privateKey: "line1\nline2", + }), + ); + }); + + it("should throw if credentials are missing", () => { + // Partial env to trigger error + const invalidEnv = { + ...mockEnv, + SECRET_FIREBASE_PROJECT_ID: "", + }; + expect(() => new FirebaseFirestoreClient(invalidEnv)).toThrowError( + /Firebase credentials/, + ); + }); + + describe("get", () => { + it("should return document data if it exists", async () => { + const mockData = { name: "Test", downloadCount: "10" }; + mockDocRef.get.mockResolvedValue({ + exists: true, + id: "doc-1", + data: () => mockData, + }); + + const result = await client.get("doc-1"); + + expect(mockDb.collection).toHaveBeenCalledWith("data"); + expect(mockCollection.doc).toHaveBeenCalledWith("doc-1"); + expect(result).toEqual({ + id: "doc-1", + name: "Test", + downloadCount: 10, + uploadedAt: undefined, + }); + }); + + it("should return null if document does not exist", async () => { + mockDocRef.get.mockResolvedValue({ + exists: false, + id: "doc-1", + data: () => null, + }); + const result = await client.get("doc-1"); + expect(result).toBeNull(); + }); + + it("should return null if get throws", async () => { + mockDocRef.get.mockRejectedValue(new Error("Network error")); + const result = await client.get("doc-1"); + expect(result).toBeNull(); + }); + }); + + describe("put", () => { + it("should add document and return it", async () => { + const newItem: Omit = { + name: "New Item", + author: "user-1", + downloadCount: 0, + encrypted: false, + additionalData: "http://example.com/blob", + }; + + mockCollection.add.mockResolvedValue({ id: "new-id" }); + + const result = await client.put(newItem); + + expect(mockCollection.add).toHaveBeenCalledWith(newItem); + expect(result).toEqual({ + id: "new-id", + ...newItem, + downloadCount: 0, + uploadedAt: undefined, + }); + }); + }); + + describe("delete", () => { + it("should delete document by id", async () => { + await client.delete("doc-1"); + expect(mockCollection.doc).toHaveBeenCalledWith("doc-1"); + expect(mockDocRef.delete).toHaveBeenCalled(); + }); + }); + + describe("update", () => { + it("should update document and return updated data", async () => { + const updateData: Partial & { id: string } = { + id: "doc-1", + name: "Updated", + }; + const existingData = { + name: "Updated", + author: "user-1", + downloadCount: 5, + }; + + mockDocRef.update.mockResolvedValue(undefined); + mockDocRef.get.mockResolvedValue({ + exists: true, + id: "doc-1", + data: () => existingData, + }); + + const result = await client.update(updateData); + + expect(mockDocRef.update).toHaveBeenCalledWith({ name: "Updated" }); + expect(result).toEqual({ + id: "doc-1", + ...existingData, + downloadCount: 5, + }); + }); + + it("should throw if updated document is not found", async () => { + mockDocRef.update.mockResolvedValue(undefined); + mockDocRef.get.mockResolvedValue({ + exists: false, + id: "doc-1", + data: () => null, + }); + + await expect( + client.update({ id: "doc-1", name: "Updated" }), + ).rejects.toThrow(/Failed to retrieve updated document/); + }); + }); + + describe("list", () => { + it("should list all documents", async () => { + const mockDocs = [ + { id: "1", data: () => ({ name: "A", downloadCount: 1 }) }, + { id: "2", data: () => ({ name: "B", downloadCount: 2 }) }, + ]; + mockCollection.get.mockResolvedValue(mockDocs); + + const result = await client.list(); + + expect(mockCollection.get).toHaveBeenCalled(); + expect(result.items).toHaveLength(2); + expect(result.items[0].id).toBe("1"); + }); + + it("should list documents with order NewestFirst", async () => { + const mockDocs: { id: string; data: () => FirestoreData }[] = []; + mockQuery.get.mockResolvedValue(mockDocs); + + await client.list(DataListOrder.NewestFirst); + + expect(mockCollection.orderBy).toHaveBeenCalledWith( + "uploadedAt", + "desc", + ); + }); + + it("should list documents with order DownloadsFirst", async () => { + const mockDocs: { id: string; data: () => FirestoreData }[] = []; + mockQuery.get.mockResolvedValue(mockDocs); + + await client.list(DataListOrder.DownloadsFirst); + + expect(mockCollection.orderBy).toHaveBeenCalledWith( + "downloadCount", + "desc", + ); + }); + }); + + describe("queryByName", () => { + it("should query documents by name", async () => { + const mockDocs = [{ id: "1", data: () => ({ name: "Target" }) }]; + mockQuery.get.mockResolvedValue(mockDocs); + + const result = await client.queryByName("Target"); + + expect(mockCollection.where).toHaveBeenCalledWith( + "name", + "==", + "Target", + ); + expect(result.items).toHaveLength(1); + + expect(result.items[0].name).toBe("Target"); + }); + }); + + describe("bumpDownloadCount", () => { + it("should increment download count", async () => { + const initialData = { downloadCount: 5 }; + mockDocRef.get.mockResolvedValue({ + exists: true, + id: "doc-1", + data: () => initialData, + }); + + await client.bumpDownloadCount("doc-1"); + + expect(mockDocRef.update).toHaveBeenCalledWith({ + downloadCount: 6, + }); + }); + + it("should handle string download count from DB", async () => { + const initialData = { downloadCount: "5" }; + mockDocRef.get.mockResolvedValue({ + exists: true, + id: "doc-1", + data: () => initialData, + }); + + await client.bumpDownloadCount("doc-1"); + + expect(mockDocRef.update).toHaveBeenCalledWith({ + downloadCount: 6, + }); + }); + + it("should do nothing if document does not exist", async () => { + mockDocRef.get.mockResolvedValue({ + exists: false, + id: "doc-1", + data: () => null, + }); + + await client.bumpDownloadCount("doc-1"); + + expect(mockDocRef.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/tests/adapters/vendor/InMemoryBlob.test.ts b/backend/tests/adapters/vendor/InMemoryBlob.test.ts new file mode 100644 index 00000000..4ac14569 --- /dev/null +++ b/backend/tests/adapters/vendor/InMemoryBlob.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import InMemoryBlob from "@/adapters/vendor/InMemoryBlob"; + +describe("InMemoryBlob", () => { + it("upload and get returns data URL", async () => { + const client = new InMemoryBlob(); + const data = new Uint8Array([1, 2, 3, 4]); + const url = await client.upload(data, "application/octet-stream"); + expect(url.startsWith("inmemory://")).toBe(true); + + const got = await client.get(url); + expect(got).toMatch(/^data:application\/octet-stream;base64,/); + }); + + it("delete removes stored blob", async () => { + const client = new InMemoryBlob(); + const data = new Uint8Array([5, 6, 7]); + const url = await client.upload(data); + const got1 = await client.get(url); + expect(got1).not.toBeNull(); + await client.delete(url); + const got2 = await client.get(url); + expect(got2).toBeNull(); + }); +}); diff --git a/backend/tests/adapters/vendor/InMemoryDB.test.ts b/backend/tests/adapters/vendor/InMemoryDB.test.ts new file mode 100644 index 00000000..67914ccc --- /dev/null +++ b/backend/tests/adapters/vendor/InMemoryDB.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DataListOrder } from "@/adapters/StorageClientBase"; +import InMemoryDataDBClient from "@/adapters/vendor/InMemoryDB"; +import type { DataType } from "@/schema"; + +const now = Date.now(); + +describe("InMemoryDataDBClient", () => { + let client: InMemoryDataDBClient; + + beforeEach(() => { + client = new InMemoryDataDBClient(); + }); + + it("put/get works and generates id", async () => { + const item: Omit = { + name: "Alice", + author: "user-1", + additionalData: "http://example.com/blob", + downloadCount: 0, + encrypted: false, + uploadedAt: now, + }; + + const created = await client.put(item); + expect(created.id).toBeTruthy(); + const fetched = await client.get(created.id); + expect(fetched).not.toBeNull(); + expect(fetched?.name).toBe("Alice"); + }); + + it("list honors order and pagination", async () => { + // create several items + for (let i = 0; i < 15; i++) { + await client.put({ + name: `Item ${i}`, + author: `u${i}`, + additionalData: "x", + downloadCount: i, + encrypted: false, + uploadedAt: now + i, + }); + } + + const page1 = await client.list(DataListOrder.NewestFirst, { + limit: 5, + pageToken: "0", + }); + expect(page1.items.length).toBe(5); + // items are sorted newest first so first page should have highest uploadedAt + expect(page1.items[0].uploadedAt ?? 0).toBeGreaterThan( + page1.items[4].uploadedAt ?? 0, + ); + + const page2 = await client.list(DataListOrder.NewestFirst, { + limit: 5, + pageToken: String(5), + }); + expect(page2.items.length).toBe(5); + expect(page1.totalCount).toBeTruthy(); + }); + + it("queryByName returns paginated results", async () => { + await client.put({ + name: "bob", + author: "a", + additionalData: "x", + downloadCount: 0, + encrypted: false, + uploadedAt: now, + }); + await client.put({ + name: "bobby", + author: "b", + additionalData: "x", + downloadCount: 0, + encrypted: false, + uploadedAt: now, + }); + + const res = await client.queryByName("bob", { + limit: 1, + pageToken: "0", + }); + expect(res.items.length).toBe(1); + expect(res.totalCount).toBeGreaterThanOrEqual(2); + }); + + it("update and bumpDownloadCount work", async () => { + const created = await client.put({ + name: "to-update", + author: "au", + additionalData: "x", + downloadCount: 0, + encrypted: false, + uploadedAt: now, + }); + + const updated = await client.update({ + id: created.id, + name: "updated", + }); + expect(updated.name).toBe("updated"); + + await client.bumpDownloadCount(created.id); + const after = await client.get(created.id); + expect(after?.downloadCount).toBeDefined(); + }); + + it("delete removes item", async () => { + const created = await client.put({ + name: "to-delete", + author: "au", + additionalData: "x", + downloadCount: 0, + encrypted: false, + uploadedAt: now, + }); + await client.delete(created.id); + const after = await client.get(created.id); + expect(after).toBeNull(); + }); +}); diff --git a/backend/tests/adapters/vendor/S3CompatibleBlob.test.ts b/backend/tests/adapters/vendor/S3CompatibleBlob.test.ts new file mode 100644 index 00000000..327ef939 --- /dev/null +++ b/backend/tests/adapters/vendor/S3CompatibleBlob.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock AwsClient from aws4fetch +const mockFetch = vi.fn(); +const mockSign = vi.fn(); +vi.mock("aws4fetch", () => ({ + AwsClient: vi.fn().mockImplementation(() => ({ + fetch: mockFetch, + sign: mockSign, + })), +})); + +import type { DBEnv } from "@/adapters/client"; +import S3BlobStorageClient from "@/adapters/vendor/S3CompatibleBlob"; + +const env: DBEnv = { + SECRET_S3_ACCESS_KEY: "ak", + SECRET_S3_SECRET_KEY: "sk", + SECRET_S3_BUCKET_NAME: "bucket", + SECRET_S3_REGION: "region", + SECRET_S3_ENDPOINT: "s3.example.com", + SECRET_CLERK_SECRET_KEY: "test-clerk-key", + ENV_CLERK_PUBLIC_KEY: "test-clerk-pub", +}; + +describe("S3BlobStorageClient", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("upload calls fetch and returns key", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + }); + const client = new S3BlobStorageClient(env); + const data = new Uint8Array([1, 2, 3]); + const key = await client.upload(data, "application/octet-stream"); + expect(typeof key).toBe("string"); + expect(mockFetch).toHaveBeenCalled(); + const call = mockFetch.mock.calls[0] as unknown[]; + const opts = call[1] as { method?: string } | undefined; + expect(opts?.method).toBe("PUT"); + }); + + it("get returns presigned url when object exists", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + }); + mockSign.mockResolvedValue({ + url: "https://signed.example.com/object", + }); + const client = new S3BlobStorageClient(env); + const res = await client.get("some-key"); + expect(res).toBe("https://signed.example.com/object"); + // HEAD should have been called + expect(mockFetch).toHaveBeenCalled(); + }); + + it("delete calls DELETE and throws on error", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + }); + const client = new S3BlobStorageClient(env); + await expect(client.delete("some-key")).resolves.toBeUndefined(); + expect(mockFetch).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/routes/blob.test.ts b/backend/tests/routes/blob.test.ts new file mode 100644 index 00000000..df82d9b3 --- /dev/null +++ b/backend/tests/routes/blob.test.ts @@ -0,0 +1,189 @@ +import { Hono } from "hono"; +import { assert, beforeEach, describe, expect, it, vi } from "vitest"; +import InMemoryBlob from "@/adapters/vendor/InMemoryBlob"; +import InMemoryDataDBClient from "@/adapters/vendor/InMemoryDB"; +import type { AuthenticatedBindings } from "@/environmentTypes"; + +// Create shared in-memory instances so route handlers see the same state +const db = new InMemoryDataDBClient(); +const blob = new InMemoryBlob(); + +vi.mock("@/adapters/client", () => ({ + DataDBClient: async () => db, + BlobClient: async () => blob, +})); + +// Lightweight auth router for tests +vi.mock("@/lib/auth", () => ({ + createAuthedHonoRouter: (_authLevel?: string) => { + const r = new Hono(); + r.use("*", (c, next) => { + const uid = c.req.header("x-user-id") || "user-1"; + c.set("user", { + authUid: uid, + name: "Test User", + role: uid === "admin" ? "admin" : "known", + }); + return next(); + }); + return r; + }, +})); + +vi.mock("@hono/clerk-auth", () => ({ + clerkMiddleware: () => (_c: unknown, next: () => Promise) => + next(), + getAuth: (c: { req: Request }) => { + const header = c.req.headers.get("x-user-id"); + if (header) return { userId: header }; + return { userId: "user-1" }; + }, +})); + +import { testClient } from "hono/testing"; +import blobApp from "@/routes/blob"; + +const blobClient = testClient(blobApp); + +beforeEach(() => { + // No-op: shared in-memory instances persist within a test file run. +}); + +describe("routes/blob", () => { + it("upload by author then GET redirects with data: URL and bumps download count", async () => { + // create item directly in DB + const created = await db.put({ + name: "WithBlob", + author: "user-1", + additionalData: "http://example.com/placeholder", + encrypted: false, + uploadedAt: Date.now(), + downloadCount: 0, + }); + + const blobData = new Uint8Array([9, 8, 7]); + const uploadRes = await blobClient.data[":id"].blob.$post( + { param: { id: created.id } }, + { + headers: { "x-user-id": "user-1" }, + init: { + body: blobData, + headers: { "content-type": "application/octet-stream" }, + }, + }, + ); + expect(uploadRes.status).toBe(200); + const uploaded = await uploadRes.json(); + assert("url" in uploaded, "uploaded should have url"); + expect(typeof uploaded.url).toBe("string"); + expect(uploaded.url.startsWith("inmemory://")).toBe(true); + + // GET should redirect to data: URL and bump download count + const getRes = await blobClient.data[":id"].blob.$get( + { param: { id: created.id } }, + { headers: { "x-user-id": "user-1" } }, + ); + expect(getRes.status).toBe(307); + const loc = getRes.headers.get("Location"); + expect(loc).toBeTruthy(); + expect(loc?.startsWith("data:")).toBe(true); + + // verify downloadCount bumped + const after = await db.get(created.id); + expect(after?.downloadCount).toBeGreaterThanOrEqual(1); + }); + + it("upload denies non-author non-admin", async () => { + const created = await db.put({ + name: "NoUpload", + author: "user-1", + additionalData: "http://example.com/placeholder", + encrypted: false, + uploadedAt: Date.now(), + downloadCount: 0, + }); + + const blobData = new Uint8Array([1]); + const uploadRes = await blobClient.data[":id"].blob.$post( + { param: { id: created.id } }, + { + headers: { "x-user-id": "other-user" }, + init: { body: blobData }, + }, + ); + expect(uploadRes.status).toBe(403); + }); + + it("upload allowed for admin", async () => { + const created = await db.put({ + name: "AdminUpload", + author: "someone", + additionalData: "http://example.com/placeholder", + encrypted: false, + uploadedAt: Date.now(), + downloadCount: 0, + }); + + const blobData = new Uint8Array([2, 3]); + const uploadRes = await blobClient.data[":id"].blob.$post( + { param: { id: created.id } }, + { + headers: { "x-user-id": "admin" }, + init: { body: blobData }, + }, + ); + expect(uploadRes.status).toBe(200); + const body = await uploadRes.json(); + assert("url" in body, "body should have url"); + expect(typeof body.url).toBe("string"); + }); + + it("upload to non-existent item returns 404", async () => { + const blobData = new Uint8Array([4]); + const uploadRes = await blobClient.data[":id"].blob.$post( + { param: { id: "no-such-id" } }, + { + headers: { "x-user-id": "admin" }, + init: { body: blobData }, + }, + ); + expect(uploadRes.status).toBe(404); + }); + + it("get returns 404 when no blob present on item", async () => { + const created = await db.put({ + name: "NoBlobHere", + author: "user-1", + additionalData: "http://example.com/placeholder", + encrypted: false, + uploadedAt: Date.now(), + downloadCount: 0, + }); + + const getRes = await blobClient.data[":id"].blob.$get( + { param: { id: created.id } }, + { headers: { "x-user-id": "user-1" } }, + ); + expect(getRes.status).toBe(404); + }); + + it("get returns 404 when storage has no blob for given URL", async () => { + // insert item with an additionalData pointing to non-existent storage id + const created = await db.put({ + name: "MissingStorage", + author: "user-1", + additionalData: "inmemory://doesnotexist", + encrypted: false, + uploadedAt: Date.now(), + downloadCount: 0, + }); + + const getRes = await blobClient.data[":id"].blob.$get( + { param: { id: created.id } }, + { headers: { "x-user-id": "user-1" } }, + ); + expect(getRes.status).toBe(404); + }); + + // Note: intentionally omit invalid-id param test due to router validation behavior in test client +}); diff --git a/backend/tests/routes/data.test.ts b/backend/tests/routes/data.test.ts new file mode 100644 index 00000000..d1d7f376 --- /dev/null +++ b/backend/tests/routes/data.test.ts @@ -0,0 +1,296 @@ +import { Hono } from "hono"; +import { assert, describe, expect, it, vi } from "vitest"; +import InMemoryBlob from "@/adapters/vendor/InMemoryBlob"; +import InMemoryDataDBClient from "@/adapters/vendor/InMemoryDB"; + +// Create shared in-memory instances so route handlers see the same state +const db = new InMemoryDataDBClient(); +const blob = new InMemoryBlob(); + +vi.mock("@/adapters/client", () => ({ + DataDBClient: async () => db, + BlobClient: async () => blob, +})); + +// Provide a lightweight auth router for tests so we don't need real Clerk env +vi.mock("@/lib/auth", () => ({ + createAuthedHonoRouter: (_authLevel?: string) => { + const r = new Hono(); + r.use("*", (c, next) => { + const uid = c.req.header("x-user-id") || "user-1"; + c.set("user", { + authUid: uid, + name: "Test User", + role: uid === "admin" ? "admin" : "known", + }); + return next(); + }); + return r; + }, +})); + +// Mock clerk auth middleware and getAuth. Use request header `x-user-id` +// to vary the authenticated user per request in tests. +vi.mock("@hono/clerk-auth", () => ({ + clerkMiddleware: () => (_c: unknown, next: () => Promise) => + next(), + getAuth: (c: { req: Request }) => { + const header = c.req.headers.get("x-user-id"); + if (header) return { userId: header }; + return { userId: "user-1" }; + }, +})); + +import { testClient } from "hono/testing"; +import type { AuthenticatedBindings } from "@/environmentTypes"; +import blobApp from "@/routes/blob"; +import app from "@/routes/data"; + +// Use testClient; linting for `any` is disabled for this test helper binding +const client = testClient(app); +const blobClient = testClient(blobApp); + +describe("routes/data", () => { + it("check returns 204 for authenticated user", async () => { + const res = await client.check.$get(); + expect(res.status).toBe(204); + }); + + it("create -> get -> list works", async () => { + const createRes = await client.data.$post({ + json: { + additionalData: "http://example.com", + encrypted: false, + name: "MyData", + author: "user-1", + description: "Test data item", + downloadCount: 0, + }, + }); + expect(createRes.status).toBe(201); + const created = await createRes.json(); + expect(created.id).toBeTruthy(); + expect(created.name).toBe("MyData"); + + // get single + const getRes = await client.data[":id"].$get({ + param: { + id: created.id, + }, + }); + expect(getRes.status).toBe(200); + const single = await getRes.json(); + expect(single.id).toBe(created.id); + + // list + const listRes = await client.data.$get({ + query: {}, + }); + expect(listRes.status).toBe(200); + const list = await listRes.json(); + assert("items" in list, "items should be in list response"); + + assert(Array.isArray(list.items)); + expect(list.items.find((i) => i.id === created.id)).toBeTruthy(); + }); + + it("patch denies non-author and allows author", async () => { + // create as default user (user-1) + const createRes = await client.data.$post({ + json: { + name: "PatchMe", + author: "user-1", + additionalData: "http://example.com", + encrypted: false, + }, + }); + const created = await createRes.json(); + + // Attempt patch as different user -> should be Forbidden (403) + const patchResForbidden = await client.data[":id"].$patch( + { + param: { id: created.id }, + json: { name: "Hacked" }, + }, + { + headers: { "x-user-id": "other-user" }, + }, + ); + expect(patchResForbidden.status).toBe(403); + + // Patch as author -> succeeds + const patchResOk = await client.data[":id"].$patch( + { + param: { id: created.id }, + json: { name: "UpdatedName" }, + }, + { + headers: { "x-user-id": "user-1" }, + }, + ); + expect(patchResOk.status).toBe(200); + const patched = await patchResOk.json(); + assert("name" in patched, "patched should have name"); + expect(patched.name).toBe("UpdatedName"); + }); + + it("delete only allowed for admin or author (admin removes)", async () => { + // create as default user + const createRes = await client.data.$post({ + json: { + name: "ToDelete", + author: "user-1", + additionalData: "http://example.com", + encrypted: false, + }, + }); + const created = await createRes.json(); + + // Attempt delete as non-admin non-author -> Forbidden + const delResForbidden = await client.data[":id"].$delete( + { param: { id: created.id } }, + { headers: { "x-user-id": "other-user" } }, + ); + expect(delResForbidden.status).toBe(403); + + // Delete as admin -> OK + const delResAdmin = await client.data[":id"].$delete( + { param: { id: created.id } }, + { headers: { "x-user-id": "admin" } }, + ); + expect(delResAdmin.status).toBe(200); + const delBody = await delResAdmin.json(); + assert("ok" in delBody, "delBody should have ok"); + expect(delBody.ok).toBe(true); + }); + + it("upload blob and then redirect to it", async () => { + // create as user-1 + const createRes = await client.data.$post({ + json: { + name: "WithBlob", + author: "user-1", + additionalData: "http://example.com", + encrypted: false, + }, + }); + const created = await createRes.json(); + + // upload binary blob as author + const blobData = new Uint8Array([1, 2, 3]); + const uploadRes = await blobClient.data[":id"].blob.$post( + { param: { id: created.id } }, + { + headers: { "x-user-id": "user-1" }, + init: { + body: blobData, + headers: { "content-type": "application/octet-stream" }, + }, + }, + ); + expect(uploadRes.status).toBe(200); + const uploaded = await uploadRes.json(); + assert("url" in uploaded, "uploaded should have url"); + expect(typeof uploaded.url).toBe("string"); + + // fetch blob redirect + const getBlobRes = await blobClient.data[":id"].blob.$get( + { param: { id: created.id } }, + { headers: { "x-user-id": "user-1" } }, + ); + expect(getBlobRes.status).toBe(307); + const loc = getBlobRes.headers.get("Location"); + expect(loc).toBeTruthy(); + expect(loc?.startsWith("data:")).toBe(true); + }); + + it("list pagination works with limit and pageToken", async () => { + // populate db directly to avoid validator constraints + const base = Date.now(); + for (let i = 0; i < 12; i++) { + // each item needs required fields + await db.put({ + name: `Paginated ${i}`, + author: `u${i}`, + additionalData: `http://blob/${i}`, + downloadCount: i, + encrypted: false, + uploadedAt: base + i, + }); + } + + const p1 = await client.data.$get({ + query: { limit: "5", pageToken: "0" }, + }); + expect(p1.status).toBe(200); + const p1body = await p1.json(); + assert("items" in p1body, "p1body should have items"); + expect(p1body.items.length).toBe(5); + expect(p1body.nextPageToken).toBeDefined(); + + const p2 = await client.data.$get({ + query: { limit: "5", pageToken: p1body.nextPageToken }, + }); + expect(p2.status).toBe(200); + const p2body = await p2.json(); + assert("items" in p2body, "p2body should have items"); + expect(p2body.items.length).toBe(5); + // third page should have remaining 2 items + const p3 = await client.data.$get({ + query: { limit: "5", pageToken: p2body.nextPageToken }, + }); + expect(p3.status).toBe(200); + const p3body = await p3.json(); + assert("items" in p3body, "p3body should have items"); + expect(p3body.items.length).toBeGreaterThanOrEqual(1); + }); + + it("queryByName supports pagination and returns totalCount", async () => { + // create matching items + await db.put({ + name: "bob", + author: "a", + additionalData: "http://x", + downloadCount: 0, + encrypted: false, + uploadedAt: Date.now(), + }); + await db.put({ + name: "bobby", + author: "b", + additionalData: "http://x", + downloadCount: 0, + encrypted: false, + uploadedAt: Date.now(), + }); + await db.put({ + name: "alice", + author: "c", + additionalData: "http://x", + downloadCount: 0, + encrypted: false, + uploadedAt: Date.now(), + }); + + const res = await client.data.$get({ + query: { name: "bob", limit: "1", pageToken: "0" }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + assert("items" in body, "body should have items"); + expect(body.items.length).toBe(1); + expect(body.totalCount).toBeGreaterThanOrEqual(2); + expect(body.nextPageToken).toBeDefined(); + }); + + it("invalid query params return 400", async () => { + const res = await client.data.$get({ query: { limit: "0" } }); + expect(res.status).toBe(400); + }); + + it("create with invalid body returns 400", async () => { + // @ts-expect-error intentionally passing invalid body for test + const res = await client.data.$post({ json: {} }); + expect(res.status).toBe(400); + }); +}); diff --git a/backend/tools/auth.html b/backend/tools/auth.html index 37d28d31..1b057297 100644 --- a/backend/tools/auth.html +++ b/backend/tools/auth.html @@ -1,508 +1,179 @@ - + - - - Clerk Dev Auth Playground - - - - - -
-

Clerk Dev Auth Playground

- -
- - -
- - - -
- -
- - -
- -
- - -
- -

- Provide a publishable key to get started. -

-
- - - -
-

Session snapshot

-
null
-
- -
-

Tokens

-
-// Fetch a token to display it here.
-
- - -
- - + - window.addEventListener("beforeunload", (e) => { - e.preventDefault(); - if (removeListener) { - removeListener(); - } - }); - - - + \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 109d5952..26f819df 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -13,5 +13,5 @@ "baseUrl": "./src", "outDir": "dist" }, - "include": ["src"] + "include": ["src", "tests", "vite.config.ts"] } diff --git a/backend/vite.config.ts b/backend/vite.config.ts index d1f4f4f9..61c21ec9 100644 --- a/backend/vite.config.ts +++ b/backend/vite.config.ts @@ -1,20 +1,39 @@ +/// import { cloudflare } from "@cloudflare/vite-plugin"; -import { defineConfig } from "vite"; +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; import tsconfigPaths from "vite-tsconfig-paths"; -export default defineConfig({ - plugins: [cloudflare(), tsconfigPaths()], - server: { - cors: false, // https://hono.dev/docs/middleware/builtin/cors#using-with-vite - }, - build: { - sourcemap: true, - lib: { - entry: "src/main.ts", - formats: ["es"], +export default defineWorkersConfig((ctx) => { + const isTestMode = ctx.mode === "test"; + const config = { + plugins: [!isTestMode && cloudflare(), tsconfigPaths()], + server: { + cors: false, // https://hono.dev/docs/middleware/builtin/cors#using-with-vite + allowedHosts: true, }, - outDir: "dist", - }, - preview: { port: 5179 }, - clearScreen: false, + build: { + sourcemap: true, + lib: { + entry: "src/main.ts", + formats: ["es"], + }, + rollupOptions: { + output: { + //Everything in a single file for Cloudflare Workers + manualChunks: () => "bundle", + }, + }, + outDir: "dist", + }, + test: { + poolOptions: { + workers: { + wrangler: { configPath: "./wrangler.jsonc" }, + }, + }, + }, + preview: { port: 5179 }, + clearScreen: false, + } satisfies import("vitest/config").ViteUserConfig; + return config; }); diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc index 811717b2..3cd000f2 100644 --- a/backend/wrangler.jsonc +++ b/backend/wrangler.jsonc @@ -10,6 +10,6 @@ "preview_urls": false, "route": { "custom_domain": true, - "pattern": "https://phonebook.back.arisutalk.moe", + "pattern": "https://phonebook.back.arisutalk.moe" } } diff --git a/frontend/.env b/frontend/.env index 84ba811f..3b13a4ea 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,7 +1,8 @@ VITE_CORS_PROXY=https://cors.back.arisutalk.moe/proxy VITE_HOST_URL=https://dev.arisutalk.moe VITE_CLERK_PUBLISHABLE_KEY=pk_test_YW1wbGUtcGVsaWNhbi01OS5jbGVyay5hY2NvdW50cy5kZXYk -VITE_PHONEBOOK_BASE_URL=https://phonebook.back.arisutalk.moe +# VITE_PHONEBOOK_BASE_URL=https://phonebook.back.arisutalk.moe +VITE_PHONEBOOK_BASE_URL=/phonebook VITE_FIREBASE_AUTH={"apiKey":"AIzaSyAQGmJ_pj3I3iH6LBDEurU-m-1eIrkHqyA","authDomain":"arisutalk-82efa.firebaseapp.com","projectId":"arisutalk-82efa","storageBucket":"arisutalk-82efa.firebasestorage.app","messagingSenderId":"783329182244","appId":"1:783329182244:web:89726a2823aec892c74eb1","measurementId":"G-YRH63EN5KM"} VITE_DEV_CONTACT="Email: concertypin@gmail.com, Signal: eurokachan.64" \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..c5ce33b2 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +.env.local +dist/ +node_modules/ \ No newline at end of file diff --git a/frontend/.vscode b/frontend/.vscode new file mode 120000 index 00000000..18144664 --- /dev/null +++ b/frontend/.vscode @@ -0,0 +1 @@ +../.vscode \ No newline at end of file diff --git a/frontend/biome.jsonc b/frontend/biome.jsonc new file mode 100644 index 00000000..ea6b23a9 --- /dev/null +++ b/frontend/biome.jsonc @@ -0,0 +1,54 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + }, + "includes": ["src"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 80, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always" + } + }, + "overrides": [ + { + "includes": ["**/*.svelte"], + "linter": { + "rules": { + "style": { + "useConst": "off", + "useImportType": "off" + }, + "correctness": { + "noUnusedVariables": "off", + "noUnusedImports": "off" + } + } + } + } + ] +} diff --git a/frontend/package.json b/frontend/package.json index 047839ff..4b90c874 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,9 @@ "prepreview": "pnpm run build", "preview": "pnpm run serve", "serve": "pnpx http-server -p 5173 -c-1 dist/", - "format": "prettier --write 'src/**/*.{js,ts,svelte,json,md}' --plugin prettier-plugin-svelte", + "format": "biome format --write .", + "lint": "biome lint .", + "check": "biome check .", "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui" @@ -23,16 +25,14 @@ "node": ">20" }, "devDependencies": { + "@biomejs/biome": "^2.3.8", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/postcss": "^4.1.13", "@vitest/coverage-v8": "^4.0.6", "@vitest/ui": "^4.0.6", "happy-dom": "^20.0.10", "jsdom": "^27.1.0", - "@trivago/prettier-plugin-sort-imports": "^5.2.2", - "lucide-svelte": "^0.544.0", "postcss": "^8.5.6", - "prettier-plugin-svelte": "^3.4.0", "svelte": "^5.39.3", "tailwindcss": "^4.1.13", "typescript": "^5.9.3", @@ -53,6 +53,7 @@ "@langchain/google-genai": "^0.2.18", "@langchain/groq": "^0.2.4", "@langchain/openai": "^0.6.16", + "@lucide/svelte": "^0.555.0", "@nyariv/sandboxjs": "^0.8.25", "comlink": "^4.4.2", "firebase": "^12.3.0", diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs index c2ddf748..78253b6d 100644 --- a/frontend/postcss.config.mjs +++ b/frontend/postcss.config.mjs @@ -1,5 +1,5 @@ export default { - plugins: { - "@tailwindcss/postcss": {}, - }, + plugins: { + "@tailwindcss/postcss": {}, + }, }; diff --git a/frontend/prettier.config.ts b/frontend/prettier.config.ts deleted file mode 100644 index ad3ca4c4..00000000 --- a/frontend/prettier.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { type Config } from "prettier"; - -const config: Config = { - trailingComma: "es5", - plugins: [ - "prettier-plugin-svelte", - "@trivago/prettier-plugin-sort-imports", - ], - importOrder: [ - "", - // absolute paths - "^\$.*", - // relative paths - "^[./]", - ], - tabWidth: 4, - - importOrderSeparation: true, - importOrderSortSpecifiers: true, -}; - -export default config; diff --git a/frontend/script/envbuild.ts b/frontend/script/envbuild.ts index f5c6838e..9f169613 100644 --- a/frontend/script/envbuild.ts +++ b/frontend/script/envbuild.ts @@ -1,4 +1,3 @@ - //@ts-check /** @@ -7,44 +6,46 @@ * @param env loadEnv(mode, process.cwd(), '') * @returns An object containing environment variables */ -export default function getEnvVar(ctx: import('vite').ConfigEnv, env: Record): Record { - const isLocal = ctx.mode !== 'production' && !env.GITHUB_ACTIONS && !env.NETLIFY; +export default function getEnvVar( + ctx: import("vite").ConfigEnv, + env: Record, +): Record { + const isLocal = + ctx.mode !== "production" && !env.GITHUB_ACTIONS && !env.NETLIFY; const baseRepo = "concertypin/ArisuTalk"; - const GITHUB_REPO_URL = env.REPOSITORY_URL // Netlify build - || "https://github.com/" + (env.GITHUB_REPOSITORY || baseRepo); + const GITHUB_REPO_URL = + env.REPOSITORY_URL || // Netlify build + "https://github.com/" + (env.GITHUB_REPOSITORY || baseRepo); // It is local build by default and be overridden by CI/CD env vars. - let versionChannel = 'local'; - let versionName = 'secret'; // Because it is local - let versionUrl = isLocal ? "https://www.youtube.com/watch?v=dQw4w9WgXcQ" // You know this + let versionChannel = "local"; + let versionName = "secret"; // Because it is local + let versionUrl = isLocal + ? "https://www.youtube.com/watch?v=dQw4w9WgXcQ" // You know this : GITHUB_REPO_URL; // It might be another one's production build, so I endure dQw4. if (env.GITHUB_ACTIONS) { - const ref = env.GITHUB_REF || "" - const sha = env.GITHUB_SHA || ''; + const ref = env.GITHUB_REF || ""; + const sha = env.GITHUB_SHA || ""; - if (ref.startsWith('refs/tags/v')) { - const tag = ref.replace('refs/tags/', ''); - versionChannel = 'prod'; + if (ref.startsWith("refs/tags/v")) { + const tag = ref.replace("refs/tags/", ""); + versionChannel = "prod"; versionName = tag.substring(1); // 'v' prefix elimination versionUrl = `${GITHUB_REPO_URL}/tree/${tag}`; - } - else if (ref.startsWith('refs/tags/dev')) { - const tag = ref.replace('refs/tags/', ''); - versionChannel = 'dev'; + } else if (ref.startsWith("refs/tags/dev")) { + const tag = ref.replace("refs/tags/", ""); + versionChannel = "dev"; versionName = tag.substring(3); // 'dev' prefix elimination versionUrl = `${GITHUB_REPO_URL}/tree/${tag}`; - } - else if (ref === 'refs/heads/main') { - versionChannel = 'spark'; + } else if (ref === "refs/heads/main") { + versionChannel = "spark"; versionName = sha.substring(0, 7); // 7-character short SHA versionUrl = `${GITHUB_REPO_URL}/commit/${sha}`; } - } - - else if (env.NETLIFY) { + } else if (env.NETLIFY) { // Netlify build for PR previews versionChannel = "PR"; versionName = env.COMMIT_REF?.substring(0, 7) || "unknown"; @@ -56,4 +57,4 @@ export default function getEnvVar(ctx: import('vite').ConfigEnv, env: Record