From 7dbe1208f63b974a75a1445b68f6a24bc2400d90 Mon Sep 17 00:00:00 2001 From: Fingertips Date: Tue, 20 Jan 2026 20:34:36 +0800 Subject: [PATCH 1/5] refactor: consume file types, add doc string and merge image upload handling --- apps/admin/src/constants/api.ts | 2 +- .../src/pages/project/_components/card.tsx | 2 +- .../pages/project/add/_components/form.tsx | 9 +- apps/admin/src/services/{image.ts => file.ts} | 28 +- apps/admin/src/types/file.ts | 162 ++++++++++ apps/admin/src/types/image.ts | 46 --- apps/admin/src/types/index.ts | 62 +++- apps/admin/src/types/project.ts | 88 +++-- apps/backend/.mockery.yaml | 2 - apps/backend/docs/docs.go | 262 +++++++-------- apps/backend/docs/swagger.json | 262 +++++++-------- apps/backend/docs/swagger.yaml | 172 +++++----- apps/backend/internal/domain/file.go | 101 ++++++ apps/backend/internal/domain/image.go | 92 ------ apps/backend/internal/handler/v1/dto.go | 31 -- apps/backend/internal/handler/v1/dto/file.go | 31 ++ apps/backend/internal/handler/v1/file.go | 101 +++++- .../v1/{image_test.go => file_test.go} | 306 +++++++++--------- apps/backend/internal/handler/v1/image.go | 152 --------- .../internal/handler/v1/mocks/file_handler.go | 46 +++ .../handler/v1/mocks/image_handler.go | 130 -------- apps/backend/internal/repository/v1/file.go | 135 +++++++- .../v1/{image_test.go => file_test.go} | 164 +++++----- apps/backend/internal/repository/v1/image.go | 148 --------- .../repository/v1/mocks/file_repository.go | 68 ++++ .../repository/v1/mocks/image_repository.go | 107 ------ apps/backend/internal/server/server.go | 13 +- 27 files changed, 1357 insertions(+), 1365 deletions(-) rename apps/admin/src/services/{image.ts => file.ts} (53%) create mode 100644 apps/admin/src/types/file.ts delete mode 100644 apps/admin/src/types/image.ts delete mode 100644 apps/backend/internal/domain/image.go rename apps/backend/internal/handler/v1/{image_test.go => file_test.go} (59%) delete mode 100644 apps/backend/internal/handler/v1/image.go delete mode 100644 apps/backend/internal/handler/v1/mocks/image_handler.go rename apps/backend/internal/repository/v1/{image_test.go => file_test.go} (84%) delete mode 100644 apps/backend/internal/repository/v1/image.go delete mode 100644 apps/backend/internal/repository/v1/mocks/image_repository.go diff --git a/apps/admin/src/constants/api.ts b/apps/admin/src/constants/api.ts index c2c29155..2ba21251 100644 --- a/apps/admin/src/constants/api.ts +++ b/apps/admin/src/constants/api.ts @@ -4,5 +4,5 @@ export const APIRoute = { githubTags: '/github/search/repositories?q=stars:>500&sort=stars&order=desc', devToTags: 'https://dev.to/api/tags', project: `${api}/project`, - image: `${api}/image`, + file: `${api}/file`, } as const; diff --git a/apps/admin/src/pages/project/_components/card.tsx b/apps/admin/src/pages/project/_components/card.tsx index aaab7b31..d63391f3 100644 --- a/apps/admin/src/pages/project/_components/card.tsx +++ b/apps/admin/src/pages/project/_components/card.tsx @@ -17,7 +17,7 @@ export function Card({ project }: CardProps) {
{`${project.title} setImageLoaded(true)} diff --git a/apps/admin/src/pages/project/add/_components/form.tsx b/apps/admin/src/pages/project/add/_components/form.tsx index e93ff2f3..29c823ac 100644 --- a/apps/admin/src/pages/project/add/_components/form.tsx +++ b/apps/admin/src/pages/project/add/_components/form.tsx @@ -13,7 +13,7 @@ import { MAX_BYTES } from '@/constants/sizes'; import { useUnsavedChanges } from '@/hooks/useUnsavedChanges'; import { toast } from '@/lib/toast'; import { Route } from '@/routes/route'; -import { ImageService } from '@/services/image'; +import { FileService } from '@/services/file'; import { ProjectService } from '@/services/project'; import { ProjectType } from '@/types/project'; @@ -127,7 +127,7 @@ export function Form() { const preview = values.preview[0]; - const url = await ImageService.upload({ + const url = await FileService.upload({ file: preview, signal: abortRef.current?.signal, }); @@ -164,7 +164,6 @@ export function Form() { const projectId = await ProjectService.create({ project: { - preview: imageURL, blurhash: values.blurhash, title: values.title, subTitle: values.subTitle, @@ -205,8 +204,8 @@ export function Form() { const ariaLabel = imageLoading ? 'Uploading image, please wait' : projectLoading - ? 'Creating project, please wait' - : 'Submit'; + ? 'Creating project, please wait' + : 'Submit'; return ( diff --git a/apps/admin/src/services/image.ts b/apps/admin/src/services/file.ts similarity index 53% rename from apps/admin/src/services/image.ts rename to apps/admin/src/services/file.ts index e418e109..5b252140 100644 --- a/apps/admin/src/services/image.ts +++ b/apps/admin/src/services/file.ts @@ -1,7 +1,17 @@ import { APIRoute } from '@/constants/api'; -import { mapImageFile } from '@/types/image'; +import { mapFileUpload } from '@/types/file'; -export const ImageService = { +/** + * FileService provides methods for uploading files to the backend via UploadThing. + */ +export const FileService = { + /** + * Uploads a file by first requesting an upload URL from the backend, then uploading the file to storage. + * + * @param file - The File object to upload + * @param signal - Optional AbortSignal to cancel the upload request + * @returns The URL of the uploaded file, or null if the upload fails + */ upload: async ({ file, signal, @@ -10,7 +20,7 @@ export const ImageService = { signal?: AbortSignal; }): Promise => { try { - const response = await fetch(`${APIRoute.image}/upload`, { + const response = await fetch(`${APIRoute.file}/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -29,21 +39,21 @@ export const ImageService = { if (!response.ok) { throw new Error( - `failed to upload project image (status: ${response.status} - ${response.statusText})`, + `failed to upload file (status: ${response.status} - ${response.statusText})`, ); } const data = await response.json(); - const imageFile = mapImageFile(data.file); + const fileUpload = mapFileUpload(data.file); const formData = new FormData(); - Object.entries(imageFile.fields).forEach(([k, v]) => { + Object.entries(fileUpload.fields).forEach(([k, v]) => { formData.append(k, v); }); formData.append('file', file, file.name); - const uploadResponse = await fetch(imageFile.URL, { + const uploadResponse = await fetch(fileUpload.URL, { method: 'POST', body: formData, signal, @@ -55,9 +65,9 @@ export const ImageService = { ); } - return imageFile.fileURL; + return fileUpload.fileURL; } catch (error) { - console.error('ImageService.upload error: ', error); + console.error('FileService.upload error: ', error); return null; } }, diff --git a/apps/admin/src/types/file.ts b/apps/admin/src/types/file.ts new file mode 100644 index 00000000..93998ac0 --- /dev/null +++ b/apps/admin/src/types/file.ts @@ -0,0 +1,162 @@ +import { ensureDate, ensureNumber, ensureString } from '.'; + +export const FileRole = { + image: 'image', +} as const; + +export type File = { + id: string; + parentTable: string; + parentID: string; + role: (typeof FileRole)[keyof typeof FileRole]; + name: string; + url: string; + type: string; + size: number; + createdAt: Date; + updatedAt: Date; +}; + +/** + * Validates that a value is a valid file role. + * + * @param value - The value to validate as a file role + * @returns The validated file role value + * @throws {Error} If the value is not a string or is not a valid file role + */ +function ensureRole(value: unknown): 'image' { + const roleValue = ensureString({ + value, + name: 'role', + }) as (typeof FileRole)[keyof typeof FileRole]; + + if (!Object.values(FileRole).includes(roleValue)) { + throw new Error(`Invalid file role: ${roleValue}`); + } + + return roleValue; +} + +/** + * Maps a DTO object to a File domain object. + * + * @param dto - The data transfer object to map + * @returns The mapped File domain object with validated properties + * @throws {Error} If the DTO is not a valid object or contains invalid properties + */ +export function mapFile(dto: unknown): File { + if (typeof dto !== 'object' || dto === null) { + throw new Error('Invalid file DTO'); + } + + const d = dto as Record; + + return { + id: ensureString({ value: d.id, name: 'id' }), + parentTable: ensureString({ value: d.parent_table, name: 'parent_table' }), + parentID: ensureString({ value: d.parent_id, name: 'parent_id' }), + role: ensureRole(d.role), + name: ensureString({ value: d.name, name: 'name' }), + url: ensureString({ value: d.url, name: 'url' }), + type: ensureString({ value: d.type, name: 'type' }), + size: ensureNumber({ value: d.size, name: 'size' }), + createdAt: ensureDate({ value: d.created_at, name: 'created_at' }), + updatedAt: ensureDate({ value: d.updated_at, name: 'updated_at' }), + }; +} + +/** + * Converts a File domain object to a JSON-serializable format. + * + * @param file - The File domain object to convert + * @returns A record with snake_case keys suitable for API serialization, excluding undefined values + */ +export function toJSONFile(file: Partial): Record { + const result: Record = { + id: file.id, + parent_table: file.parentTable, + parent_id: file.parentID, + role: file.role, + name: file.name, + url: file.url, + type: file.type, + size: file.size, + created_at: file.createdAt ? file.createdAt.toISOString() : undefined, + updated_at: file.updatedAt ? file.updatedAt.toISOString() : undefined, + }; + + // Filter out undefined values + return Object.fromEntries( + Object.entries(result).filter(([, v]) => v !== undefined), + ); +} + +// -------------------- UPLOADTHING types below -------------------- + +/** + * Represents a file that has been successfully uploaded via UploadThing. + */ +export type FileUpload = { + key: string; + fileName: string; + fileType: string; + fileURL: string; + contentDisposition: string; + pollingJWT: string; + pollingURL: string; + customId?: string; + URL: string; + fields: Record; +}; + +/** + * Validates and converts an unknown value to a fields object with string values. + * + * @param value - The value to validate and convert as fields + * @returns An object with string values, or an empty object if value is not a valid object + * @throws {Error} If any field value is not a string + */ +function ensureFields(value: unknown): { [k: string]: string } { + return value && typeof value === 'object' + ? Object.fromEntries( + Object.entries(value).map(([k, v]) => [ + k, + ensureString({ value: v, name: k }), + ]), + ) + : {}; +} + +/** + * Maps a DTO object to a FileUpload domain object. + * + * @param dto - The data transfer object to map + * @returns The mapped FileUpload domain object with validated properties + * @throws {Error} If the DTO is not a valid object or contains invalid properties + */ +export function mapFileUpload(dto: unknown): FileUpload { + if (typeof dto !== 'object' || dto === null) { + throw new Error('Invalid file DTO'); + } + + const d = dto as Record; + + return { + key: ensureString({ value: d.key, name: 'key' }), + fileName: ensureString({ value: d.file_name, name: 'file_name' }), + fileType: ensureString({ value: d.file_type, name: 'file_type' }), + fileURL: ensureString({ value: d.file_url, name: 'file_url' }), + contentDisposition: ensureString({ + value: d.content_disposition, + name: 'content_disposition', + }), + pollingJWT: ensureString({ value: d.polling_jwt, name: 'polling_jwt' }), + pollingURL: ensureString({ value: d.polling_url, name: 'polling_url' }), + customId: + d.custom_id != null + ? ensureString({ value: d.custom_id, name: 'custom_id' }) + : undefined, + URL: ensureString({ value: d.url, name: 'url' }), + fields: ensureFields(d.fields), + }; +} diff --git a/apps/admin/src/types/image.ts b/apps/admin/src/types/image.ts deleted file mode 100644 index 95c30fe0..00000000 --- a/apps/admin/src/types/image.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ensureString } from '.'; - -export type ImageFile = { - key: string; - fileName: string; - fileType: string; - fileURL: string; - contentDisposition: string; - pollingJWT: string; - pollingURL: string; - customId?: string; - URL: string; - fields: Record; -}; - -export function mapImageFile(dto: unknown): ImageFile { - if (typeof dto !== 'object' || dto === null) { - throw new Error('Invalid image DTO'); - } - - const d = dto as Record; - - const fields = - d.fields && typeof d.fields === 'object' - ? Object.fromEntries( - Object.entries(d.fields).map(([k, v]) => [k, ensureString(v, k)]), - ) - : {}; - - return { - key: ensureString(d.key, 'key'), - fileName: ensureString(d.file_name, 'file_name'), - fileType: ensureString(d.file_type, 'file_type'), - fileURL: ensureString(d.file_url, 'file_url'), - contentDisposition: ensureString( - d.content_disposition, - 'content_disposition', - ), - pollingJWT: ensureString(d.polling_jwt, 'polling_jwt'), - pollingURL: ensureString(d.polling_url, 'polling_url'), - customId: - d.custom_id != null ? ensureString(d.custom_id, 'custom_id') : undefined, - URL: ensureString(d.url, 'url'), - fields, - }; -} diff --git a/apps/admin/src/types/index.ts b/apps/admin/src/types/index.ts index e6e89dc1..a6554346 100644 --- a/apps/admin/src/types/index.ts +++ b/apps/admin/src/types/index.ts @@ -1,4 +1,18 @@ -export function ensureString(value: unknown, name: string): string { +/** + * Ensures a value is a string, coercing numbers and booleans if necessary. + * @param {Object} options - The options object + * @param {unknown} options.value - The value to validate and coerce + * @param {string} options.name - The property name for error messages + * @returns {string} The validated or coerced string value + * @throws {Error} If the value cannot be coerced to a string + */ +export function ensureString({ + value, + name, +}: { + value: unknown; + name: string; +}): string { if (typeof value === 'string') return value; // Coerce numbers/booleans to strings for flexibility with JSON responses if (typeof value === 'number' || typeof value === 'boolean') { @@ -6,3 +20,49 @@ export function ensureString(value: unknown, name: string): string { } throw new Error(`Expected property '${name}' to be a string`); } + +/** + * Ensures a value is a valid Date, coercing from string representation if necessary. + * @param {Object} options - The options object + * @param {unknown} options.value - The value to validate and coerce + * @param {string} options.name - The property name for error messages + * @returns {Date} The validated or coerced Date value + * @throws {Error} If the value cannot be coerced to a valid Date + */ +export function ensureDate({ + value, + name, +}: { + value: unknown; + name: string; +}): Date { + const date = new Date(ensureString({ value, name })); + if (isNaN(date.getTime())) { + throw new Error(`Invalid date for property '${name}'`); + } + + return date; +} + +/** + * Ensures a value is a valid number, coercing from string representation if necessary. + * @param {Object} options - The options object + * @param {unknown} options.value - The value to validate and coerce + * @param {string} options.name - The property name for error messages + * @returns {number} The validated or coerced number value + * @throws {Error} If the value cannot be coerced to a valid number + */ +export function ensureNumber({ + value, + name, +}: { + value: unknown; + name: string; +}): number { + const num = Number(ensureString({ value, name })); + if (isNaN(num)) { + throw new Error(`Invalid number for property '${name}'`); + } + + return num; +} diff --git a/apps/admin/src/types/project.ts b/apps/admin/src/types/project.ts index 18aaaa3d..196d6a0a 100644 --- a/apps/admin/src/types/project.ts +++ b/apps/admin/src/types/project.ts @@ -1,4 +1,5 @@ -import { ensureString } from '.'; +import { ensureDate, ensureString } from '.'; +import { type File, mapFile, toJSONFile } from './file'; export const ProjectType = { web: 'web', @@ -8,7 +9,7 @@ export const ProjectType = { export type Project = { id: string; - preview: string; + previews: File[]; blurhash: string; title: string; subTitle: string; @@ -21,6 +22,33 @@ export type Project = { updatedAt: Date; }; +/** + * Validates that a value is a valid project type. + * + * @param value - The value to validate as a project type + * @returns The validated project type value + * @throws {Error} If the value is not a string or is not a valid project type + */ +function ensureType(value: unknown): 'web' | 'mobile' | 'game' { + const typeValue = ensureString({ + value: value, + name: 'type', + }) as (typeof ProjectType)[keyof typeof ProjectType]; + + if (!Object.values(ProjectType).includes(typeValue)) { + throw new Error(`Invalid project type: ${typeValue}`); + } + + return typeValue; +} + +/** + * Maps a DTO object to a Project domain object. + * + * @param dto - The data transfer object to map + * @returns The mapped Project domain object with validated properties + * @throws {Error} If the DTO is not a valid object or contains invalid properties + */ export function mapProject(dto: unknown): Project { if (typeof dto !== 'object' || dto === null) { throw new Error('Invalid project DTO'); @@ -28,53 +56,43 @@ export function mapProject(dto: unknown): Project { const d = dto as Record; - const typeValue = ensureString( - d.type, - 'type', - ) as (typeof ProjectType)[keyof typeof ProjectType]; - if (!Object.values(ProjectType).includes(typeValue)) { - throw new Error(`Invalid project type: ${typeValue}`); - } - return { - id: ensureString(d.id, 'id'), - preview: ensureString(d.preview, 'preview'), - blurhash: ensureString(d.blurhash, 'blurhash'), - title: ensureString(d.title, 'title'), - subTitle: ensureString(d.sub_title, 'sub_title'), - description: ensureString(d.description, 'description'), + id: ensureString({ value: d.id, name: 'id' }), + previews: Array.isArray(d.previews) + ? d.previews.map((file) => mapFile(file)) + : [], + blurhash: ensureString({ value: d.blurhash, name: 'blurhash' }), + title: ensureString({ value: d.title, name: 'title' }), + subTitle: ensureString({ value: d.sub_title, name: 'sub_title' }), + description: ensureString({ value: d.description, name: 'description' }), tags: Array.isArray(d.tags) - ? d.tags.map((tag, index) => ensureString(tag, `tags[${index}]`)) + ? d.tags.map((tag, index) => + ensureString({ value: tag, name: `tags[${index}]` }), + ) : [], - type: typeValue, - link: ensureString(d.link, 'link'), + type: ensureType(d.type), + link: ensureString({ value: d.link, name: 'link' }), educationId: d.education_id != null - ? ensureString(d.education_id, 'education_id') + ? ensureString({ value: d.education_id, name: 'education_id' }) : undefined, - createdAt: (() => { - const date = new Date(ensureString(d.created_at, 'created_at')); - if (isNaN(date.getTime())) { - throw new Error('Invalid created_at date'); - } - return date; - })(), - updatedAt: (() => { - const date = new Date(ensureString(d.updated_at, 'updated_at')); - if (isNaN(date.getTime())) { - throw new Error('Invalid updated_at date'); - } - return date; - })(), + createdAt: ensureDate({ value: d.created_at, name: 'created_at' }), + updatedAt: ensureDate({ value: d.updated_at, name: 'updated_at' }), }; } +/** + * Converts a Project domain object to a JSON-serializable format. + * + * @param project - The Project domain object to convert + * @returns A record with snake_case keys suitable for API serialization, excluding undefined values + */ export function toJSONProject( project: Partial, ): Record { const result: Record = { id: project.id, - preview: project.preview, + previews: project.previews?.map((file) => toJSONFile(file)), blurhash: project.blurhash, title: project.title, sub_title: project.subTitle, diff --git a/apps/backend/.mockery.yaml b/apps/backend/.mockery.yaml index 5857b042..91584fd0 100644 --- a/apps/backend/.mockery.yaml +++ b/apps/backend/.mockery.yaml @@ -15,7 +15,6 @@ packages: ProjectRepository: {} EducationRepository: {} SkillRepository: {} - ImageRepository: {} FileRepository: {} github.com/fingertips18/fingertips18.github.io/backend/internal/handler/v1: interfaces: @@ -24,7 +23,6 @@ packages: ProjectHandler: {} EducationHandler: {} SkillHandler: {} - ImageHandler: {} FileHandler: {} github.com/fingertips18/fingertips18.github.io/backend/internal/database: interfaces: diff --git a/apps/backend/docs/docs.go b/apps/backend/docs/docs.go index 07613f35..51a3dd4e 100644 --- a/apps/backend/docs/docs.go +++ b/apps/backend/docs/docs.go @@ -511,6 +511,57 @@ const docTemplate = `{ } } }, + "/file/upload": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Handles file upload with the supplied metadata and returns the Uploadthing URL of the stored file.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "file" + ], + "summary": "Upload a file", + "parameters": [ + { + "description": "File upload payload", + "name": "fileUpload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.FileUploadRequestDTO" + } + } + ], + "responses": { + "202": { + "description": "File upload URL", + "schema": { + "$ref": "#/definitions/dto.FileUploadedResponseDTO" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/file/{id}": { "get": { "security": [ @@ -721,57 +772,6 @@ const docTemplate = `{ } } }, - "/image/upload": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Handles image upload with the supplied metadata and returns the Uploadthing URL of the stored image.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "image" - ], - "summary": "Upload an image", - "parameters": [ - { - "description": "Image upload payload", - "name": "imageUpload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1.ImageUploadRequestDTO" - } - } - ], - "responses": { - "202": { - "description": "Image upload URL", - "schema": { - "$ref": "#/definitions/v1.ImageUploadResponseDTO" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, "/project": { "put": { "security": [ @@ -1538,6 +1538,86 @@ const docTemplate = `{ } } }, + "dto.FileUploadDTO": { + "type": "object", + "properties": { + "custom_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "dto.FileUploadRequestDTO": { + "type": "object", + "properties": { + "acl": { + "type": "string" + }, + "content_disposition": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.FileUploadDTO" + } + }, + "metadata": {} + } + }, + "dto.FileUploadedDTO": { + "type": "object", + "properties": { + "content_disposition": { + "type": "string" + }, + "custom_id": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": {} + }, + "file_name": { + "type": "string" + }, + "file_type": { + "type": "string" + }, + "file_url": { + "type": "string" + }, + "key": { + "type": "string" + }, + "polling_jwt": { + "type": "string" + }, + "polling_url": { + "type": "string" + }, + "url": { + "description": "signed URL to upload", + "type": "string" + } + } + }, + "dto.FileUploadedResponseDTO": { + "type": "object", + "properties": { + "file": { + "$ref": "#/definitions/dto.FileUploadedDTO" + } + } + }, "dto.ProjectDTO": { "type": "object", "properties": { @@ -1688,23 +1768,6 @@ const docTemplate = `{ } } }, - "v1.FileDTO": { - "type": "object", - "properties": { - "custom_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "type": { - "type": "string" - } - } - }, "v1.IDResponse": { "type": "object", "properties": { @@ -1713,69 +1776,6 @@ const docTemplate = `{ } } }, - "v1.ImageUploadFileDTO": { - "type": "object", - "properties": { - "content_disposition": { - "type": "string" - }, - "custom_id": { - "type": "string" - }, - "fields": { - "type": "object", - "additionalProperties": {} - }, - "file_name": { - "type": "string" - }, - "file_type": { - "type": "string" - }, - "file_url": { - "type": "string" - }, - "key": { - "type": "string" - }, - "polling_jwt": { - "type": "string" - }, - "polling_url": { - "type": "string" - }, - "url": { - "description": "signed URL to upload", - "type": "string" - } - } - }, - "v1.ImageUploadRequestDTO": { - "type": "object", - "properties": { - "acl": { - "type": "string" - }, - "content_disposition": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/definitions/v1.FileDTO" - } - }, - "metadata": {} - } - }, - "v1.ImageUploadResponseDTO": { - "type": "object", - "properties": { - "file": { - "$ref": "#/definitions/v1.ImageUploadFileDTO" - } - } - }, "v1.SkillDTO": { "type": "object", "properties": { diff --git a/apps/backend/docs/swagger.json b/apps/backend/docs/swagger.json index bb8bacff..68fb09e2 100644 --- a/apps/backend/docs/swagger.json +++ b/apps/backend/docs/swagger.json @@ -503,6 +503,57 @@ } } }, + "/file/upload": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Handles file upload with the supplied metadata and returns the Uploadthing URL of the stored file.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "file" + ], + "summary": "Upload a file", + "parameters": [ + { + "description": "File upload payload", + "name": "fileUpload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.FileUploadRequestDTO" + } + } + ], + "responses": { + "202": { + "description": "File upload URL", + "schema": { + "$ref": "#/definitions/dto.FileUploadedResponseDTO" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/file/{id}": { "get": { "security": [ @@ -713,57 +764,6 @@ } } }, - "/image/upload": { - "post": { - "security": [ - { - "ApiKeyAuth": [] - } - ], - "description": "Handles image upload with the supplied metadata and returns the Uploadthing URL of the stored image.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "image" - ], - "summary": "Upload an image", - "parameters": [ - { - "description": "Image upload payload", - "name": "imageUpload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1.ImageUploadRequestDTO" - } - } - ], - "responses": { - "202": { - "description": "Image upload URL", - "schema": { - "$ref": "#/definitions/v1.ImageUploadResponseDTO" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, "/project": { "put": { "security": [ @@ -1530,6 +1530,86 @@ } } }, + "dto.FileUploadDTO": { + "type": "object", + "properties": { + "custom_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "dto.FileUploadRequestDTO": { + "type": "object", + "properties": { + "acl": { + "type": "string" + }, + "content_disposition": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.FileUploadDTO" + } + }, + "metadata": {} + } + }, + "dto.FileUploadedDTO": { + "type": "object", + "properties": { + "content_disposition": { + "type": "string" + }, + "custom_id": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": {} + }, + "file_name": { + "type": "string" + }, + "file_type": { + "type": "string" + }, + "file_url": { + "type": "string" + }, + "key": { + "type": "string" + }, + "polling_jwt": { + "type": "string" + }, + "polling_url": { + "type": "string" + }, + "url": { + "description": "signed URL to upload", + "type": "string" + } + } + }, + "dto.FileUploadedResponseDTO": { + "type": "object", + "properties": { + "file": { + "$ref": "#/definitions/dto.FileUploadedDTO" + } + } + }, "dto.ProjectDTO": { "type": "object", "properties": { @@ -1680,23 +1760,6 @@ } } }, - "v1.FileDTO": { - "type": "object", - "properties": { - "custom_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "type": { - "type": "string" - } - } - }, "v1.IDResponse": { "type": "object", "properties": { @@ -1705,69 +1768,6 @@ } } }, - "v1.ImageUploadFileDTO": { - "type": "object", - "properties": { - "content_disposition": { - "type": "string" - }, - "custom_id": { - "type": "string" - }, - "fields": { - "type": "object", - "additionalProperties": {} - }, - "file_name": { - "type": "string" - }, - "file_type": { - "type": "string" - }, - "file_url": { - "type": "string" - }, - "key": { - "type": "string" - }, - "polling_jwt": { - "type": "string" - }, - "polling_url": { - "type": "string" - }, - "url": { - "description": "signed URL to upload", - "type": "string" - } - } - }, - "v1.ImageUploadRequestDTO": { - "type": "object", - "properties": { - "acl": { - "type": "string" - }, - "content_disposition": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/definitions/v1.FileDTO" - } - }, - "metadata": {} - } - }, - "v1.ImageUploadResponseDTO": { - "type": "object", - "properties": { - "file": { - "$ref": "#/definitions/v1.ImageUploadFileDTO" - } - } - }, "v1.SkillDTO": { "type": "object", "properties": { diff --git a/apps/backend/docs/swagger.yaml b/apps/backend/docs/swagger.yaml index d469db8b..931f8414 100644 --- a/apps/backend/docs/swagger.yaml +++ b/apps/backend/docs/swagger.yaml @@ -120,6 +120,59 @@ definitions: url: type: string type: object + dto.FileUploadDTO: + properties: + custom_id: + type: string + name: + type: string + size: + type: integer + type: + type: string + type: object + dto.FileUploadRequestDTO: + properties: + acl: + type: string + content_disposition: + type: string + files: + items: + $ref: '#/definitions/dto.FileUploadDTO' + type: array + metadata: {} + type: object + dto.FileUploadedDTO: + properties: + content_disposition: + type: string + custom_id: + type: string + fields: + additionalProperties: {} + type: object + file_name: + type: string + file_type: + type: string + file_url: + type: string + key: + type: string + polling_jwt: + type: string + polling_url: + type: string + url: + description: signed URL to upload + type: string + type: object + dto.FileUploadedResponseDTO: + properties: + file: + $ref: '#/definitions/dto.FileUploadedDTO' + type: object dto.ProjectDTO: properties: blurhash: @@ -219,64 +272,11 @@ definitions: error: type: string type: object - v1.FileDTO: - properties: - custom_id: - type: string - name: - type: string - size: - type: integer - type: - type: string - type: object v1.IDResponse: properties: id: type: string type: object - v1.ImageUploadFileDTO: - properties: - content_disposition: - type: string - custom_id: - type: string - fields: - additionalProperties: {} - type: object - file_name: - type: string - file_type: - type: string - file_url: - type: string - key: - type: string - polling_jwt: - type: string - polling_url: - type: string - url: - description: signed URL to upload - type: string - type: object - v1.ImageUploadRequestDTO: - properties: - acl: - type: string - content_disposition: - type: string - files: - items: - $ref: '#/definitions/v1.FileDTO' - type: array - metadata: {} - type: object - v1.ImageUploadResponseDTO: - properties: - file: - $ref: '#/definitions/v1.ImageUploadFileDTO' - type: object v1.SkillDTO: properties: category: @@ -717,6 +717,39 @@ paths: summary: Get a file by ID tags: - file + /file/upload: + post: + consumes: + - application/json + description: Handles file upload with the supplied metadata and returns the + Uploadthing URL of the stored file. + parameters: + - description: File upload payload + in: body + name: fileUpload + required: true + schema: + $ref: '#/definitions/dto.FileUploadRequestDTO' + produces: + - application/json + responses: + "202": + description: File upload URL + schema: + $ref: '#/definitions/dto.FileUploadedResponseDTO' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - ApiKeyAuth: [] + summary: Upload a file + tags: + - file /files: delete: description: Deletes all file records associated with a specific parent entity. @@ -789,39 +822,6 @@ paths: summary: List files by parent tags: - file - /image/upload: - post: - consumes: - - application/json - description: Handles image upload with the supplied metadata and returns the - Uploadthing URL of the stored image. - parameters: - - description: Image upload payload - in: body - name: imageUpload - required: true - schema: - $ref: '#/definitions/v1.ImageUploadRequestDTO' - produces: - - application/json - responses: - "202": - description: Image upload URL - schema: - $ref: '#/definitions/v1.ImageUploadResponseDTO' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - security: - - ApiKeyAuth: [] - summary: Upload an image - tags: - - image /project: post: consumes: diff --git a/apps/backend/internal/domain/file.go b/apps/backend/internal/domain/file.go index ba8e7e02..e983800a 100644 --- a/apps/backend/internal/domain/file.go +++ b/apps/backend/internal/domain/file.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "regexp" + "strconv" "strings" "time" @@ -44,6 +45,7 @@ type File struct { UpdatedAt time.Time `json:"updated_at"` } +// isValid validates that the ParentTable is one of the allowed parent table types. func (pt ParentTable) isValid() error { if strings.TrimSpace(string(pt)) == "" { return errors.New("parent_table missing") @@ -57,6 +59,7 @@ func (pt ParentTable) isValid() error { } } +// isValid validates that the FileRole is one of the allowed file role types. func (fr FileRole) isValid() error { if strings.TrimSpace(string(fr)) == "" { return errors.New("role missing") @@ -70,6 +73,7 @@ func (fr FileRole) isValid() error { } } +// isValidMimeType validates that the given MIME type string is in a valid format. func isValidMimeType(mimeType string) error { if strings.TrimSpace(mimeType) == "" { return errors.New("type missing") @@ -83,6 +87,7 @@ func isValidMimeType(mimeType string) error { return errors.New("invalid type") } +// isValidUUID validates that the given id is a valid UUID format. func isValidUUID(id string, label string) error { if strings.TrimSpace(id) == "" { return fmt.Errorf("%s missing", label) @@ -95,6 +100,7 @@ func isValidUUID(id string, label string) error { return nil } +// isValidURL validates that the given value is a valid HTTP or HTTPS URL. func isValidURL(value string) error { value = strings.TrimSpace(value) if value == "" { @@ -115,6 +121,7 @@ func isValidURL(value string) error { return nil } +// ValidatePayload validates the File payload fields required for creating or updating a file. func (f File) ValidatePayload() error { if err := f.ParentTable.isValid(); err != nil { return err @@ -140,6 +147,7 @@ func (f File) ValidatePayload() error { return nil } +// ValidateResponse validates all File fields including those set by the server (id, created_at, updated_at). func (f File) ValidateResponse() error { if err := isValidUUID(f.ID, "id"); err != nil { return err @@ -158,3 +166,96 @@ func (f File) ValidateResponse() error { } return nil } + +// -------------------- UPLOADTHING types below -------------------- + +// FileUpload represents a single file to be uploaded via UploadThing. +type FileUpload struct { + Name string `json:"name"` + Size int64 `json:"size"` + Type string `json:"type"` + CustomID *string `json:"customId,omitempty"` +} + +// FileUploadRequest represents a request to upload files via UploadThing with optional metadata and configuration. +type FileUploadRequest struct { + Files []FileUpload `json:"files"` + ACL *string `json:"acl,omitempty"` + Metadata any `json:"metadata,omitempty"` + ContentDisposition *string `json:"contentDisposition,omitempty"` +} + +// Validate validates that the FileUploadRequest has all required fields and valid values. +func (i FileUploadRequest) Validate() error { + // Files cannot be empty + if len(i.Files) == 0 { + return errors.New("files missing") + } + + for idx, f := range i.Files { + if f.Name == "" { + return errors.New("file[" + strconv.Itoa(idx) + "]: name missing") + } + if f.Size <= 0 { + return errors.New("file[" + strconv.Itoa(idx) + "]: size invalid") + } + if f.Type == "" { + return errors.New("file[" + strconv.Itoa(idx) + "]: type missing") + } + } + + // UploadThing ACL accepts: private, public-read + if i.ACL != nil && *i.ACL != "" { + if *i.ACL != "public-read" && *i.ACL != "private" { + return errors.New("acl must be 'public-read' or 'private'") + } + } + + // UploadThing ContentDisposition accepts: inline, attachment + if i.ContentDisposition != nil && *i.ContentDisposition != "" { + if *i.ContentDisposition != "inline" && *i.ContentDisposition != "attachment" { + return errors.New("contentDisposition must be 'inline' or 'attachment'") + } + } + + return nil +} + +// FileUploaded represents a file that has been successfully uploaded via UploadThing. +type FileUploaded struct { + Key string `json:"key"` + FileName string `json:"fileName"` + FileType string `json:"fileType"` + FileUrl string `json:"fileUrl"` + ContentDisposition string `json:"contentDisposition"` + PollingJwt string `json:"pollingJwt"` + PollingUrl string `json:"pollingUrl"` + CustomId *string `json:"customId,omitempty"` + URL string `json:"url"` // signed URL to upload + Fields map[string]any `json:"fields,omitempty"` +} + +// FileUploadedResponse represents the response from UploadThing containing uploaded file information. +type FileUploadedResponse struct { + Data []FileUploaded `json:"data"` +} + +// Validate validates that the FileUploadedResponse has valid uploaded file data. +func (r FileUploadedResponse) Validate() error { + if len(r.Data) == 0 { + return errors.New("uploadthing: response returned no files") + } + + for idx, f := range r.Data { + prefix := fmt.Sprintf("uploadthing: data[%d]", idx) + + if f.Key == "" { + return fmt.Errorf("%s.key missing", prefix) + } + if f.URL == "" { + return fmt.Errorf("%s.url missing", prefix) + } + } + + return nil +} diff --git a/apps/backend/internal/domain/image.go b/apps/backend/internal/domain/image.go deleted file mode 100644 index 3b520f21..00000000 --- a/apps/backend/internal/domain/image.go +++ /dev/null @@ -1,92 +0,0 @@ -package domain - -import ( - "errors" - "fmt" - "strconv" -) - -type ImageFile struct { - Name string `json:"name"` - Size int64 `json:"size"` - Type string `json:"type"` - CustomID *string `json:"customId,omitempty"` -} - -type ImageUploadRequest struct { - Files []ImageFile `json:"files"` - ACL *string `json:"acl,omitempty"` - Metadata any `json:"metadata,omitempty"` - ContentDisposition *string `json:"contentDisposition,omitempty"` -} - -func (i ImageUploadRequest) Validate() error { - // Files cannot be empty - if len(i.Files) == 0 { - return errors.New("files missing") - } - - for idx, f := range i.Files { - if f.Name == "" { - return errors.New("file[" + strconv.Itoa(idx) + "]: name missing") - } - if f.Size <= 0 { - return errors.New("file[" + strconv.Itoa(idx) + "]: size invalid") - } - if f.Type == "" { - return errors.New("file[" + strconv.Itoa(idx) + "]: type missing") - } - } - - // UploadThing ACL accepts: private, public-read - if i.ACL != nil && *i.ACL != "" { - if *i.ACL != "public-read" && *i.ACL != "private" { - return errors.New("acl must be 'public-read' or 'private'") - } - } - - // UploadThing ContentDisposition accepts: inline, attachment - if i.ContentDisposition != nil && *i.ContentDisposition != "" { - if *i.ContentDisposition != "inline" && *i.ContentDisposition != "attachment" { - return errors.New("contentDisposition must be 'inline' or 'attachment'") - } - } - - return nil -} - -type ImageUploadFile struct { - Key string `json:"key"` - FileName string `json:"fileName"` - FileType string `json:"fileType"` - FileUrl string `json:"fileUrl"` - ContentDisposition string `json:"contentDisposition"` - PollingJwt string `json:"pollingJwt"` - PollingUrl string `json:"pollingUrl"` - CustomId *string `json:"customId,omitempty"` - URL string `json:"url"` // signed URL to upload - Fields map[string]any `json:"fields,omitempty"` -} - -type ImageUploadResponse struct { - Data []ImageUploadFile `json:"data"` -} - -func (r ImageUploadResponse) Validate() error { - if len(r.Data) == 0 { - return errors.New("uploadthing: response returned no files") - } - - for idx, f := range r.Data { - prefix := fmt.Sprintf("uploadthing: data[%d]", idx) - - if f.Key == "" { - return fmt.Errorf("%s.key missing", prefix) - } - if f.URL == "" { - return fmt.Errorf("%s.url missing", prefix) - } - } - - return nil -} diff --git a/apps/backend/internal/handler/v1/dto.go b/apps/backend/internal/handler/v1/dto.go index 6bd6dc6c..d8dc260c 100644 --- a/apps/backend/internal/handler/v1/dto.go +++ b/apps/backend/internal/handler/v1/dto.go @@ -45,37 +45,6 @@ type SkillFilterRequest struct { Category string `json:"category"` } -type FileDTO struct { - Name string `json:"name"` - Size int64 `json:"size"` - Type string `json:"type"` - CustomID *string `json:"custom_id,omitempty"` -} - -type ImageUploadRequestDTO struct { - Files []FileDTO `json:"files"` - ACL *string `json:"acl,omitempty"` - Metadata any `json:"metadata,omitempty"` - ContentDisposition *string `json:"content_disposition,omitempty"` -} - -type ImageUploadFileDTO struct { - Key string `json:"key"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` - FileUrl string `json:"file_url"` - ContentDisposition string `json:"content_disposition"` - PollingJwt string `json:"polling_jwt"` - PollingUrl string `json:"polling_url"` - CustomId *string `json:"custom_id,omitempty"` - URL string `json:"url"` // signed URL to upload - Fields map[string]any `json:"fields,omitempty"` -} - -type ImageUploadResponseDTO struct { - File ImageUploadFileDTO `json:"file"` -} - type IDResponse struct { Id string `json:"id"` } diff --git a/apps/backend/internal/handler/v1/dto/file.go b/apps/backend/internal/handler/v1/dto/file.go index 86f546b4..c70556fe 100644 --- a/apps/backend/internal/handler/v1/dto/file.go +++ b/apps/backend/internal/handler/v1/dto/file.go @@ -24,3 +24,34 @@ type FileDTO struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +type FileUploadDTO struct { + Name string `json:"name"` + Size int64 `json:"size"` + Type string `json:"type"` + CustomID *string `json:"custom_id,omitempty"` +} + +type FileUploadRequestDTO struct { + Files []FileUploadDTO `json:"files"` + ACL *string `json:"acl,omitempty"` + Metadata any `json:"metadata,omitempty"` + ContentDisposition *string `json:"content_disposition,omitempty"` +} + +type FileUploadedDTO struct { + Key string `json:"key"` + FileName string `json:"file_name"` + FileType string `json:"file_type"` + FileUrl string `json:"file_url"` + ContentDisposition string `json:"content_disposition"` + PollingJwt string `json:"polling_jwt"` + PollingUrl string `json:"polling_url"` + CustomId *string `json:"custom_id,omitempty"` + URL string `json:"url"` // signed URL to upload + Fields map[string]any `json:"fields,omitempty"` +} + +type FileUploadedResponseDTO struct { + File FileUploadedDTO `json:"file"` +} diff --git a/apps/backend/internal/handler/v1/file.go b/apps/backend/internal/handler/v1/file.go index 4fd4daab..a04a1261 100644 --- a/apps/backend/internal/handler/v1/file.go +++ b/apps/backend/internal/handler/v1/file.go @@ -22,10 +22,12 @@ type FileHandler interface { Delete(w http.ResponseWriter, r *http.Request, id string) DeleteByParent(w http.ResponseWriter, r *http.Request, parentTable, parentID string) ListByParent(w http.ResponseWriter, r *http.Request) + Upload(w http.ResponseWriter, r *http.Request) } type FileServiceConfig struct { - DatabaseAPI database.DatabaseAPI + DatabaseAPI database.DatabaseAPI + UploadthingSecretKey string fileRepo v1.FileRepository } @@ -44,8 +46,9 @@ func NewFileServiceHandler(cfg FileServiceConfig) FileHandler { if fileRepo == nil { fileRepo = v1.NewFileRepository( v1.FileRepositoryConfig{ - DatabaseAPI: cfg.DatabaseAPI, - FileTable: "File", + DatabaseAPI: cfg.DatabaseAPI, + FileTable: "File", + UploadthingSecretKey: cfg.UploadthingSecretKey, }, ) } @@ -68,6 +71,9 @@ func (h *fileServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := strings.TrimSuffix(r.URL.Path, "/") switch { + case path == "/file/upload": + h.Upload(w, r) + // GET /files?parent_table=...&parent_id=...&role=... case path == "/files": switch r.Method { @@ -458,3 +464,92 @@ func (h *fileServiceHandler) ListByParent(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusOK) w.Write(buf.Bytes()) } + +// Upload handles HTTP POST requests to upload file files. +// It expects a JSON request body containing file metadata and upload configuration. +// The method validates the HTTP method, decodes the request body, converts DTOs to domain objects, +// and delegates the upload operation to the file repository. +// On success, it returns a 202 Accepted status with a JSON response containing the uploaded URL. +// On failure, it returns appropriate HTTP error status codes with error messages. +// +// @Security ApiKeyAuth +// @Summary Upload a file +// @Description Handles file upload with the supplied metadata and returns the Uploadthing URL of the stored file. +// @Tags file +// @Accept json +// @Produce json +// @Param fileUpload body dto.FileUploadRequestDTO true "File upload payload" +// @Success 202 {object} dto.FileUploadedResponseDTO "File upload URL" +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /file/upload [post] +func (h *fileServiceHandler) Upload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed: only POST is supported", http.StatusMethodNotAllowed) + return + } + + defer r.Body.Close() + + var req dto.FileUploadRequestDTO + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON in request body", http.StatusBadRequest) + return + } + + var files []domain.FileUpload + for _, f := range req.Files { + files = append(files, domain.FileUpload{ + Name: f.Name, + Size: f.Size, + Type: f.Type, + CustomID: f.CustomID, + }) + } + upload := domain.FileUploadRequest{ + Files: files, + ACL: req.ACL, + Metadata: req.Metadata, + ContentDisposition: req.ContentDisposition, + } + + image, err := h.fileRepo.Upload(r.Context(), &upload) + if err != nil { + // The error in the repo is comprehensive enough + // Ensure that the first letter is capitalize + msg := err.Error() + if len(msg) > 0 { + msg = strings.ToUpper(msg[:1]) + msg[1:] + } + + http.Error(w, msg, http.StatusInternalServerError) + return + } + + file := dto.FileUploadedDTO{ + Key: image.Key, + FileName: image.FileName, + FileType: image.FileType, + FileUrl: image.FileUrl, + ContentDisposition: image.ContentDisposition, + PollingJwt: image.PollingJwt, + PollingUrl: image.PollingUrl, + CustomId: image.CustomId, + URL: image.URL, + Fields: image.Fields, + } + + resp := dto.FileUploadedResponseDTO{ + File: file, + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(resp); err != nil { + http.Error(w, "Failed to write response: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + w.Write(buf.Bytes()) +} diff --git a/apps/backend/internal/handler/v1/image_test.go b/apps/backend/internal/handler/v1/file_test.go similarity index 59% rename from apps/backend/internal/handler/v1/image_test.go rename to apps/backend/internal/handler/v1/file_test.go index 3cb3efae..a1605a39 100644 --- a/apps/backend/internal/handler/v1/image_test.go +++ b/apps/backend/internal/handler/v1/file_test.go @@ -11,47 +11,48 @@ import ( "testing" "github.com/fingertips18/fingertips18.github.io/backend/internal/domain" + "github.com/fingertips18/fingertips18.github.io/backend/internal/handler/v1/dto" mockRepo "github.com/fingertips18/fingertips18.github.io/backend/internal/repository/v1/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) -type imageHandlerTestFixture struct { - t *testing.T - mockImageRepo *mockRepo.MockImageRepository - imageHandler ImageHandler +type fileHandlerTestFixture struct { + t *testing.T + mockFileRepo *mockRepo.MockFileRepository + fileHandler FileHandler } -func newImageHandlerTestFixture(t *testing.T) *imageHandlerTestFixture { - mockImageRepo := new(mockRepo.MockImageRepository) +func newFileHandlerTestFixture(t *testing.T) *fileHandlerTestFixture { + mockFileRepo := new(mockRepo.MockFileRepository) - imageHandler := NewImageServiceHandler( - ImageServiceConfig{ - imageRepo: mockImageRepo, + fileHandler := NewFileServiceHandler( + FileServiceConfig{ + fileRepo: mockFileRepo, }, ) - return &imageHandlerTestFixture{ - t: t, - mockImageRepo: mockImageRepo, - imageHandler: imageHandler, + return &fileHandlerTestFixture{ + t: t, + mockFileRepo: mockFileRepo, + fileHandler: fileHandler, } } -func TestImageServiceHandler_Upload(t *testing.T) { - validFile := FileDTO{ +func TestFileServiceHandler_Upload(t *testing.T) { + validFile := dto.FileUploadDTO{ Name: "profile.jpg", Size: 1024, Type: "image/jpeg", } - validReq := ImageUploadRequestDTO{ - Files: []FileDTO{validFile}, + validReq := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{validFile}, } validBody, _ := json.Marshal(validReq) customID := "custom-123" - expectedImageUploadFile := &domain.ImageUploadFile{ + expectedUploadedFile := &domain.FileUploaded{ Key: "abc123", FileName: "profile.jpg", FileType: "image/jpeg", @@ -64,25 +65,25 @@ func TestImageServiceHandler_Upload(t *testing.T) { Fields: map[string]interface{}{"key": "value"}, } - expectedResp, _ := json.Marshal(ImageUploadResponseDTO{ - File: ImageUploadFileDTO{ - Key: expectedImageUploadFile.Key, - FileName: expectedImageUploadFile.FileName, - FileType: expectedImageUploadFile.FileType, - FileUrl: expectedImageUploadFile.FileUrl, - ContentDisposition: expectedImageUploadFile.ContentDisposition, - PollingJwt: expectedImageUploadFile.PollingJwt, - PollingUrl: expectedImageUploadFile.PollingUrl, - CustomId: expectedImageUploadFile.CustomId, - URL: expectedImageUploadFile.URL, - Fields: expectedImageUploadFile.Fields, + expectedResp, _ := json.Marshal(dto.FileUploadedResponseDTO{ + File: dto.FileUploadedDTO{ + Key: expectedUploadedFile.Key, + FileName: expectedUploadedFile.FileName, + FileType: expectedUploadedFile.FileType, + FileUrl: expectedUploadedFile.FileUrl, + ContentDisposition: expectedUploadedFile.ContentDisposition, + PollingJwt: expectedUploadedFile.PollingJwt, + PollingUrl: expectedUploadedFile.PollingUrl, + CustomId: expectedUploadedFile.CustomId, + URL: expectedUploadedFile.URL, + Fields: expectedUploadedFile.Fields, }, }) type Given struct { method string body string - mockRepo func(m *mockRepo.MockImageRepository) + mockRepo func(m *mockRepo.MockFileRepository) } type Expected struct { code int @@ -97,15 +98,15 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: string(validBody), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 1 && req.Files[0].Name == "profile.jpg" && req.Files[0].Size == 1024 && req.Files[0].Type == "image/jpeg" })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -147,9 +148,9 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: string(validBody), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.AnythingOfType("*domain.ImageUploadRequest")). + Upload(mock.Anything, mock.AnythingOfType("*domain.FileUploadRequest")). Return(nil, errors.New("upload failed")) }, }, @@ -162,8 +163,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: func() string { - req := ImageUploadRequestDTO{ - Files: []FileDTO{ + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{ {Name: "image1.jpg", Size: 1024, Type: "image/jpeg"}, {Name: "image2.png", Size: 2048, Type: "image/png"}, {Name: "image3.gif", Size: 512, Type: "image/gif"}, @@ -172,15 +173,15 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 3 && req.Files[0].Name == "image1.jpg" && req.Files[1].Name == "image2.png" && req.Files[2].Name == "image3.gif" })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -192,18 +193,18 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: func() string { - req := ImageUploadRequestDTO{ - Files: []FileDTO{}, + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{}, } b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 0 })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -217,8 +218,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { body: func() string { acl := "public-read" contentDisposition := "inline" - req := ImageUploadRequestDTO{ - Files: []FileDTO{validFile}, + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{validFile}, ACL: &acl, ContentDisposition: &contentDisposition, Metadata: map[string]string{ @@ -229,16 +230,16 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return req.ACL != nil && *req.ACL == "public-read" && req.ContentDisposition != nil && *req.ContentDisposition == "inline" && req.Metadata != nil })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -251,8 +252,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { method: http.MethodPost, body: func() string { customID := "custom-file-id-123" - req := ImageUploadRequestDTO{ - Files: []FileDTO{ + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{ { Name: "profile.jpg", Size: 1024, @@ -264,14 +265,14 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 1 && req.Files[0].CustomID != nil && *req.Files[0].CustomID == "custom-file-id-123" })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -283,8 +284,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: func() string { - req := ImageUploadRequestDTO{ - Files: []FileDTO{ + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{ { Name: "large-image.jpg", Size: 104857600, // 100MB @@ -295,12 +296,12 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 1 && req.Files[0].Size == 104857600 })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -312,8 +313,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: func() string { - req := ImageUploadRequestDTO{ - Files: []FileDTO{ + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{ { Name: "画像ファイル-🖼️.jpg", Size: 1024, @@ -324,13 +325,13 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 1 && strings.Contains(req.Files[0].Name, "画像ファイル") })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -342,8 +343,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: func() string { - req := ImageUploadRequestDTO{ - Files: []FileDTO{ + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{ { Name: strings.Repeat("a", 500) + ".jpg", Size: 1024, @@ -354,12 +355,12 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 1 && len(req.Files[0].Name) > 500 })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -371,8 +372,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: func() string { - req := ImageUploadRequestDTO{ - Files: []FileDTO{ + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{ {Name: "image.jpg", Size: 1024, Type: "image/jpeg"}, {Name: "image.png", Size: 2048, Type: "image/png"}, {Name: "image.gif", Size: 512, Type: "image/gif"}, @@ -383,12 +384,12 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 5 })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -400,10 +401,10 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: `{"files":[{"name":"test.jpg","size":1024,"type":"image/jpeg"}],"extra_field":"ignored"}`, - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.AnythingOfType("*domain.ImageUploadRequest")). - Return(expectedImageUploadFile, nil) + Upload(mock.Anything, mock.AnythingOfType("*domain.FileUploadRequest")). + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -415,8 +416,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: func() string { - req := ImageUploadRequestDTO{ - Files: []FileDTO{ + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{ { Name: "empty.jpg", Size: 0, @@ -427,12 +428,12 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 1 && req.Files[0].Size == 0 })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -444,8 +445,8 @@ func TestImageServiceHandler_Upload(t *testing.T) { given: Given{ method: http.MethodPost, body: func() string { - req := ImageUploadRequestDTO{ - Files: []FileDTO{validFile}, + req := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{validFile}, Metadata: map[string]interface{}{ "user": map[string]string{ "id": "123", @@ -459,12 +460,12 @@ func TestImageServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockImageRepository) { + mockRepo: func(m *mockRepo.MockFileRepository) { m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return req.Metadata != nil })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) }, }, expected: Expected{ @@ -476,16 +477,16 @@ func TestImageServiceHandler_Upload(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - f := newImageHandlerTestFixture(t) + f := newFileHandlerTestFixture(t) if tt.given.mockRepo != nil { - tt.given.mockRepo(f.mockImageRepo) + tt.given.mockRepo(f.mockFileRepo) } - req := httptest.NewRequest(tt.given.method, "/image/upload", strings.NewReader(tt.given.body)) + req := httptest.NewRequest(tt.given.method, "/file/upload", strings.NewReader(tt.given.body)) w := httptest.NewRecorder() - f.imageHandler.Upload(w, req) + f.fileHandler.Upload(w, req) res := w.Result() defer res.Body.Close() @@ -499,25 +500,25 @@ func TestImageServiceHandler_Upload(t *testing.T) { assert.Equal(t, tt.expected.body, string(body)) } - f.mockImageRepo.AssertExpectations(t) + f.mockFileRepo.AssertExpectations(t) }) } } -func TestImageServiceHandler_Upload_Routing(t *testing.T) { - validFile := FileDTO{ +func TestFileServiceHandler_Upload_Routing(t *testing.T) { + validFile := dto.FileUploadDTO{ Name: "profile.jpg", Size: 1024, Type: "image/jpeg", } - validReq := ImageUploadRequestDTO{ - Files: []FileDTO{validFile}, + validReq := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{validFile}, } validBody, _ := json.Marshal(validReq) customID := "custom-123" - expectedImageUploadFile := &domain.ImageUploadFile{ + expectedUploadedFile := &domain.FileUploaded{ Key: "abc123", FileName: "profile.jpg", FileType: "image/jpeg", @@ -530,38 +531,38 @@ func TestImageServiceHandler_Upload_Routing(t *testing.T) { Fields: map[string]interface{}{"key": "value"}, } - expectedResp, _ := json.Marshal(ImageUploadResponseDTO{ - File: ImageUploadFileDTO{ - Key: expectedImageUploadFile.Key, - FileName: expectedImageUploadFile.FileName, - FileType: expectedImageUploadFile.FileType, - FileUrl: expectedImageUploadFile.FileUrl, - ContentDisposition: expectedImageUploadFile.ContentDisposition, - PollingJwt: expectedImageUploadFile.PollingJwt, - PollingUrl: expectedImageUploadFile.PollingUrl, - CustomId: expectedImageUploadFile.CustomId, - URL: expectedImageUploadFile.URL, - Fields: expectedImageUploadFile.Fields, + expectedResp, _ := json.Marshal(dto.FileUploadedResponseDTO{ + File: dto.FileUploadedDTO{ + Key: expectedUploadedFile.Key, + FileName: expectedUploadedFile.FileName, + FileType: expectedUploadedFile.FileType, + FileUrl: expectedUploadedFile.FileUrl, + ContentDisposition: expectedUploadedFile.ContentDisposition, + PollingJwt: expectedUploadedFile.PollingJwt, + PollingUrl: expectedUploadedFile.PollingUrl, + CustomId: expectedUploadedFile.CustomId, + URL: expectedUploadedFile.URL, + Fields: expectedUploadedFile.Fields, }, }) - f := newImageHandlerTestFixture(t) + f := newFileHandlerTestFixture(t) // Mock expectation - f.mockImageRepo.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + f.mockFileRepo.EXPECT(). + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 1 && req.Files[0].Name == "profile.jpg" })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) // Create request - req := httptest.NewRequest(http.MethodPost, "/image/upload", bytes.NewReader(validBody)) + req := httptest.NewRequest(http.MethodPost, "/file/upload", bytes.NewReader(validBody)) w := httptest.NewRecorder() // Verify handler implements http.Handler - handler, ok := f.imageHandler.(http.Handler) - assert.True(t, ok, "imageHandler should implement http.Handler") + handler, ok := f.fileHandler.(http.Handler) + assert.True(t, ok, "fileHandler should implement http.Handler") // Route through ServeHTTP handler.ServeHTTP(w, req) @@ -574,20 +575,20 @@ func TestImageServiceHandler_Upload_Routing(t *testing.T) { assert.Equal(t, http.StatusAccepted, res.StatusCode) assert.JSONEq(t, string(expectedResp), string(body)) - f.mockImageRepo.AssertExpectations(t) + f.mockFileRepo.AssertExpectations(t) } -func TestImageServiceHandler_ServeHTTP_NotFound(t *testing.T) { - f := newImageHandlerTestFixture(t) +func TestFileServiceHandler_ServeHTTP_NotFound(t *testing.T) { + f := newFileHandlerTestFixture(t) tests := []struct { name string path string }{ - {"root path", "/image"}, - {"invalid path", "/image/invalid"}, - {"nested path", "/image/upload/extra"}, - {"different path", "/image/download"}, + {"root path", "/upload"}, + {"invalid path", "/upload/invalid"}, + {"nested path", "/upload/upload/extra"}, + {"different path", "/upload/download"}, } for _, tt := range tests { @@ -595,9 +596,8 @@ func TestImageServiceHandler_ServeHTTP_NotFound(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tt.path, nil) w := httptest.NewRecorder() - handler, ok := f.imageHandler.(http.Handler) - assert.True(t, ok, "imageHandler should implement http.Handler") - + handler, ok := f.fileHandler.(http.Handler) + assert.True(t, ok, "fileHandler should implement http.Handler") handler.ServeHTTP(w, req) res := w.Result() @@ -608,20 +608,20 @@ func TestImageServiceHandler_ServeHTTP_NotFound(t *testing.T) { } } -func TestImageServiceHandler_ServeHTTP_TrailingSlash(t *testing.T) { - validFile := FileDTO{ +func TestFileServiceHandler_ServeHTTP_TrailingSlash(t *testing.T) { + validFile := dto.FileUploadDTO{ Name: "profile.jpg", Size: 1024, Type: "image/jpeg", } - validReq := ImageUploadRequestDTO{ - Files: []FileDTO{validFile}, + validReq := dto.FileUploadRequestDTO{ + Files: []dto.FileUploadDTO{validFile}, } validBody, _ := json.Marshal(validReq) customID := "custom-123" - expectedImageUploadFile := &domain.ImageUploadFile{ + expectedUploadedFile := &domain.FileUploaded{ Key: "abc123", FileName: "profile.jpg", FileType: "image/jpeg", @@ -634,36 +634,36 @@ func TestImageServiceHandler_ServeHTTP_TrailingSlash(t *testing.T) { Fields: map[string]interface{}{"key": "value"}, } - expectedResp, _ := json.Marshal(ImageUploadResponseDTO{ - File: ImageUploadFileDTO{ - Key: expectedImageUploadFile.Key, - FileName: expectedImageUploadFile.FileName, - FileType: expectedImageUploadFile.FileType, - FileUrl: expectedImageUploadFile.FileUrl, - ContentDisposition: expectedImageUploadFile.ContentDisposition, - PollingJwt: expectedImageUploadFile.PollingJwt, - PollingUrl: expectedImageUploadFile.PollingUrl, - CustomId: expectedImageUploadFile.CustomId, - URL: expectedImageUploadFile.URL, - Fields: expectedImageUploadFile.Fields, + expectedResp, _ := json.Marshal(dto.FileUploadedResponseDTO{ + File: dto.FileUploadedDTO{ + Key: expectedUploadedFile.Key, + FileName: expectedUploadedFile.FileName, + FileType: expectedUploadedFile.FileType, + FileUrl: expectedUploadedFile.FileUrl, + ContentDisposition: expectedUploadedFile.ContentDisposition, + PollingJwt: expectedUploadedFile.PollingJwt, + PollingUrl: expectedUploadedFile.PollingUrl, + CustomId: expectedUploadedFile.CustomId, + URL: expectedUploadedFile.URL, + Fields: expectedUploadedFile.Fields, }, }) - f := newImageHandlerTestFixture(t) + f := newFileHandlerTestFixture(t) // Mock expectation - f.mockImageRepo.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool { + f.mockFileRepo.EXPECT(). + Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { return len(req.Files) == 1 })). - Return(expectedImageUploadFile, nil) + Return(expectedUploadedFile, nil) // Create request with trailing slash - req := httptest.NewRequest(http.MethodPost, "/image/upload/", bytes.NewReader(validBody)) + req := httptest.NewRequest(http.MethodPost, "/file/upload/", bytes.NewReader(validBody)) w := httptest.NewRecorder() - handler, ok := f.imageHandler.(http.Handler) - assert.True(t, ok, "imageHandler should implement http.Handler") + handler, ok := f.fileHandler.(http.Handler) + assert.True(t, ok, "fileHandler should implement http.Handler") handler.ServeHTTP(w, req) @@ -674,5 +674,5 @@ func TestImageServiceHandler_ServeHTTP_TrailingSlash(t *testing.T) { assert.Equal(t, http.StatusAccepted, res.StatusCode) assert.JSONEq(t, string(expectedResp), string(body)) - f.mockImageRepo.AssertExpectations(t) + f.mockFileRepo.AssertExpectations(t) } diff --git a/apps/backend/internal/handler/v1/image.go b/apps/backend/internal/handler/v1/image.go deleted file mode 100644 index fba12efa..00000000 --- a/apps/backend/internal/handler/v1/image.go +++ /dev/null @@ -1,152 +0,0 @@ -package v1 - -import ( - "bytes" - "encoding/json" - "net/http" - "strings" - - "github.com/fingertips18/fingertips18.github.io/backend/internal/domain" - v1 "github.com/fingertips18/fingertips18.github.io/backend/internal/repository/v1" -) - -type ImageHandler interface { - http.Handler - Upload(w http.ResponseWriter, r *http.Request) -} - -type ImageServiceConfig struct { - UploadthingSecretKey string - - imageRepo v1.ImageRepository -} - -type imageServiceHandler struct { - imageRepo v1.ImageRepository -} - -// NewImageServiceHandler returns an ImageHandler configured from the provided cfg. -// If cfg.imageRepo is nil, a default v1.ImageRepository is created using -// cfg.UploadthingSecretKey. The resulting ImageHandler is an *imageServiceHandler -// whose imageRepo field is set to the provided or constructed repository. -func NewImageServiceHandler(cfg ImageServiceConfig) ImageHandler { - imageRepo := cfg.imageRepo - if imageRepo == nil { - imageRepo = v1.NewImageRepository( - v1.ImageRepositoryConfig{ - UploadthingSecretKey: cfg.UploadthingSecretKey, - }, - ) - } - - return &imageServiceHandler{ - imageRepo: imageRepo, - } -} - -// ServeHTTP handles HTTP requests for image operations. -// It routes requests to appropriate handlers based on the URL path. -// Supported routes: -// - POST /image/upload: Uploads an image -// -// For unrecognized paths, it returns a 404 Not Found response. -func (h *imageServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - path := strings.TrimSuffix(r.URL.Path, "/") - path = strings.TrimPrefix(path, "/image") - - switch path { - case "/upload": - h.Upload(w, r) - default: - http.NotFound(w, r) - } -} - -// Upload handles HTTP POST requests to upload image files. -// It expects a JSON request body containing file metadata and upload configuration. -// The method validates the HTTP method, decodes the request body, converts DTOs to domain objects, -// and delegates the upload operation to the image repository. -// On success, it returns a 202 Accepted status with a JSON response containing the uploaded URL. -// On failure, it returns appropriate HTTP error status codes with error messages. -// -// @Security ApiKeyAuth -// @Summary Upload an image -// @Description Handles image upload with the supplied metadata and returns the Uploadthing URL of the stored image. -// @Tags image -// @Accept json -// @Produce json -// @Param imageUpload body ImageUploadRequestDTO true "Image upload payload" -// @Success 202 {object} ImageUploadResponseDTO "Image upload URL" -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /image/upload [post] -func (h *imageServiceHandler) Upload(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed: only POST is supported", http.StatusMethodNotAllowed) - return - } - - defer r.Body.Close() - - var req ImageUploadRequestDTO - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON in request body", http.StatusBadRequest) - return - } - - var files []domain.ImageFile - for _, f := range req.Files { - files = append(files, domain.ImageFile{ - Name: f.Name, - Size: f.Size, - Type: f.Type, - CustomID: f.CustomID, - }) - } - upload := domain.ImageUploadRequest{ - Files: files, - ACL: req.ACL, - Metadata: req.Metadata, - ContentDisposition: req.ContentDisposition, - } - - image, err := h.imageRepo.Upload(r.Context(), &upload) - if err != nil { - // The error in the repo is comprehensive enough - // Ensure that the first letter is capitalize - msg := err.Error() - if len(msg) > 0 { - msg = strings.ToUpper(msg[:1]) + msg[1:] - } - - http.Error(w, msg, http.StatusInternalServerError) - return - } - - file := ImageUploadFileDTO{ - Key: image.Key, - FileName: image.FileName, - FileType: image.FileType, - FileUrl: image.FileUrl, - ContentDisposition: image.ContentDisposition, - PollingJwt: image.PollingJwt, - PollingUrl: image.PollingUrl, - CustomId: image.CustomId, - URL: image.URL, - Fields: image.Fields, - } - - resp := ImageUploadResponseDTO{ - File: file, - } - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(resp); err != nil { - http.Error(w, "Failed to write response: "+err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - w.Write(buf.Bytes()) -} diff --git a/apps/backend/internal/handler/v1/mocks/file_handler.go b/apps/backend/internal/handler/v1/mocks/file_handler.go index 8b857a66..cc471d45 100644 --- a/apps/backend/internal/handler/v1/mocks/file_handler.go +++ b/apps/backend/internal/handler/v1/mocks/file_handler.go @@ -382,3 +382,49 @@ func (_c *MockFileHandler_Update_Call) RunAndReturn(run func(w http.ResponseWrit _c.Run(run) return _c } + +// Upload provides a mock function for the type MockFileHandler +func (_mock *MockFileHandler) Upload(w http.ResponseWriter, r *http.Request) { + _mock.Called(w, r) + return +} + +// MockFileHandler_Upload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upload' +type MockFileHandler_Upload_Call struct { + *mock.Call +} + +// Upload is a helper method to define mock.On call +// - w http.ResponseWriter +// - r *http.Request +func (_e *MockFileHandler_Expecter) Upload(w interface{}, r interface{}) *MockFileHandler_Upload_Call { + return &MockFileHandler_Upload_Call{Call: _e.mock.On("Upload", w, r)} +} + +func (_c *MockFileHandler_Upload_Call) Run(run func(w http.ResponseWriter, r *http.Request)) *MockFileHandler_Upload_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 http.ResponseWriter + if args[0] != nil { + arg0 = args[0].(http.ResponseWriter) + } + var arg1 *http.Request + if args[1] != nil { + arg1 = args[1].(*http.Request) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockFileHandler_Upload_Call) Return() *MockFileHandler_Upload_Call { + _c.Call.Return() + return _c +} + +func (_c *MockFileHandler_Upload_Call) RunAndReturn(run func(w http.ResponseWriter, r *http.Request)) *MockFileHandler_Upload_Call { + _c.Run(run) + return _c +} diff --git a/apps/backend/internal/handler/v1/mocks/image_handler.go b/apps/backend/internal/handler/v1/mocks/image_handler.go deleted file mode 100644 index de0bfe70..00000000 --- a/apps/backend/internal/handler/v1/mocks/image_handler.go +++ /dev/null @@ -1,130 +0,0 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify - -package v1 - -import ( - "net/http" - - mock "github.com/stretchr/testify/mock" -) - -// NewMockImageHandler creates a new instance of MockImageHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockImageHandler(t interface { - mock.TestingT - Cleanup(func()) -}) *MockImageHandler { - mock := &MockImageHandler{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockImageHandler is an autogenerated mock type for the ImageHandler type -type MockImageHandler struct { - mock.Mock -} - -type MockImageHandler_Expecter struct { - mock *mock.Mock -} - -func (_m *MockImageHandler) EXPECT() *MockImageHandler_Expecter { - return &MockImageHandler_Expecter{mock: &_m.Mock} -} - -// ServeHTTP provides a mock function for the type MockImageHandler -func (_mock *MockImageHandler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) { - _mock.Called(responseWriter, request) - return -} - -// MockImageHandler_ServeHTTP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServeHTTP' -type MockImageHandler_ServeHTTP_Call struct { - *mock.Call -} - -// ServeHTTP is a helper method to define mock.On call -// - responseWriter http.ResponseWriter -// - request *http.Request -func (_e *MockImageHandler_Expecter) ServeHTTP(responseWriter interface{}, request interface{}) *MockImageHandler_ServeHTTP_Call { - return &MockImageHandler_ServeHTTP_Call{Call: _e.mock.On("ServeHTTP", responseWriter, request)} -} - -func (_c *MockImageHandler_ServeHTTP_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockImageHandler_ServeHTTP_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 http.ResponseWriter - if args[0] != nil { - arg0 = args[0].(http.ResponseWriter) - } - var arg1 *http.Request - if args[1] != nil { - arg1 = args[1].(*http.Request) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockImageHandler_ServeHTTP_Call) Return() *MockImageHandler_ServeHTTP_Call { - _c.Call.Return() - return _c -} - -func (_c *MockImageHandler_ServeHTTP_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockImageHandler_ServeHTTP_Call { - _c.Run(run) - return _c -} - -// Upload provides a mock function for the type MockImageHandler -func (_mock *MockImageHandler) Upload(w http.ResponseWriter, r *http.Request) { - _mock.Called(w, r) - return -} - -// MockImageHandler_Upload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upload' -type MockImageHandler_Upload_Call struct { - *mock.Call -} - -// Upload is a helper method to define mock.On call -// - w http.ResponseWriter -// - r *http.Request -func (_e *MockImageHandler_Expecter) Upload(w interface{}, r interface{}) *MockImageHandler_Upload_Call { - return &MockImageHandler_Upload_Call{Call: _e.mock.On("Upload", w, r)} -} - -func (_c *MockImageHandler_Upload_Call) Run(run func(w http.ResponseWriter, r *http.Request)) *MockImageHandler_Upload_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 http.ResponseWriter - if args[0] != nil { - arg0 = args[0].(http.ResponseWriter) - } - var arg1 *http.Request - if args[1] != nil { - arg1 = args[1].(*http.Request) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockImageHandler_Upload_Call) Return() *MockImageHandler_Upload_Call { - _c.Call.Return() - return _c -} - -func (_c *MockImageHandler_Upload_Call) RunAndReturn(run func(w http.ResponseWriter, r *http.Request)) *MockImageHandler_Upload_Call { - _c.Run(run) - return _c -} diff --git a/apps/backend/internal/repository/v1/file.go b/apps/backend/internal/repository/v1/file.go index e3a7da92..5357cd8f 100644 --- a/apps/backend/internal/repository/v1/file.go +++ b/apps/backend/internal/repository/v1/file.go @@ -1,11 +1,17 @@ package v1 import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "io" + "log" + "net/http" "time" + "github.com/fingertips18/fingertips18.github.io/backend/internal/client" "github.com/fingertips18/fingertips18.github.io/backend/internal/database" "github.com/fingertips18/fingertips18.github.io/backend/internal/domain" "github.com/fingertips18/fingertips18.github.io/backend/internal/utils" @@ -19,19 +25,24 @@ type FileRepository interface { Delete(ctx context.Context, id string) error DeleteByParent(ctx context.Context, parentTable string, parentID string) error FindByID(ctx context.Context, id string) (*domain.File, error) + Upload(ctx context.Context, file *domain.FileUploadRequest) (*domain.FileUploaded, error) } type FileRepositoryConfig struct { - DatabaseAPI database.DatabaseAPI - FileTable string + DatabaseAPI database.DatabaseAPI + FileTable string + UploadthingSecretKey string + httpAPI client.HttpAPI timeProvider domain.TimeProvider } type fileRepository struct { - fileTable string - databaseAPI database.DatabaseAPI - timeProvider domain.TimeProvider + fileTable string + databaseAPI database.DatabaseAPI + uploadthingSecretKey string + httpAPI client.HttpAPI + timeProvider domain.TimeProvider } // NewFileRepository creates and returns a configured FileRepository. @@ -42,15 +53,22 @@ type fileRepository struct { // as the time provider. The returned value implements the // FileRepository interface and is never nil. func NewFileRepository(cfg FileRepositoryConfig) FileRepository { + httpAPI := cfg.httpAPI + if httpAPI == nil { + httpAPI = client.NewHTTPAPI(30 * time.Second) + } + timeProvider := cfg.timeProvider if timeProvider == nil { timeProvider = time.Now } return &fileRepository{ - fileTable: cfg.FileTable, - databaseAPI: cfg.DatabaseAPI, - timeProvider: timeProvider, + fileTable: cfg.FileTable, + databaseAPI: cfg.DatabaseAPI, + uploadthingSecretKey: cfg.UploadthingSecretKey, + httpAPI: httpAPI, + timeProvider: timeProvider, } } @@ -376,3 +394,104 @@ func (r *fileRepository) FindByID(ctx context.Context, id string) (*domain.File, return &file, nil } + +// Upload uploads the provided UploadthingUploadRequest to the UploadThing service and +// returns the URL of the uploaded file or an error. +// +// Behavior: +// - Validates the incoming request via image.Validate() and returns an error if invalid. +// - Applies default fallback values when not provided (ACL => "public-read", +// ContentDisposition => "inline"). +// - Marshals the request to JSON and issues an HTTP POST to +// "https://api.uploadthing.com/v6/uploadFiles" using the repository's httpAPI and the +// repository's Uploadthing API key (r.uploadthingSecretKey). The HTTP request is executed +// with the provided ctx. +// - Treats any non-200 (OK) response as an error and includes the response status and body +// in the returned error for diagnostics. +// - Decodes the successful response into domain.FileUploadedResponse, validates it, +// and returns the file metadata of the first returned file (uploadResp.Data[0]). +// - All underlying errors are wrapped with context for easier debugging. +// +// Logging: the method logs the upload attempt and the resulting uploaded file URL on success. +// +// Return values: +// - *domain.FileUploadedResponse: the file metadata on success, or nil on failure. +// - error: non-nil if validation, marshaling, network, decoding, or response validation fails. +func (r *fileRepository) Upload(ctx context.Context, file *domain.FileUploadRequest) (*domain.FileUploaded, error) { + // Validate request structure + if err := file.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate file: %w", err) + } + + log.Println("Attempting to upload the file...") + + payload := *file + + // Default fallback values + if payload.ACL == nil { + acl := "public-read" + payload.ACL = &acl + } + if payload.ContentDisposition == nil { + contentDisposition := "inline" + payload.ContentDisposition = &contentDisposition + } + + // Marshal request payload + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + // Build HTTP request + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + "https://api.uploadthing.com/v6/uploadFiles", + bytes.NewBuffer(body), + ) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Uploadthing-Api-Key", r.uploadthingSecretKey) + + // Execute request + resp, err := r.httpAPI.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send HTTP request: %w", err) + } + defer resp.Body.Close() + + // Handle non-200 response + if resp.StatusCode != http.StatusOK { + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + log.Printf("failed to read error response body: %v", readErr) + } + return nil, fmt.Errorf( + "failed to upload file: status=%s message=%s", + resp.Status, + respBody, + ) + } + + // Decode UploadThing success response + var uploadResp domain.FileUploadedResponse + if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { + return nil, fmt.Errorf("failed to decode uploadthing response: %w", err) + } + + // Make sure at least one file was returned + if err := uploadResp.Validate(); err != nil { + return nil, fmt.Errorf("invalid uploadthing response: %w", err) + } + + // Extract file URL + data := uploadResp.Data[0] + + log.Println("File uploaded successfully:", data.FileName) + + return &data, nil +} diff --git a/apps/backend/internal/repository/v1/image_test.go b/apps/backend/internal/repository/v1/file_test.go similarity index 84% rename from apps/backend/internal/repository/v1/image_test.go rename to apps/backend/internal/repository/v1/file_test.go index a69f1a89..669e8fc4 100644 --- a/apps/backend/internal/repository/v1/image_test.go +++ b/apps/backend/internal/repository/v1/file_test.go @@ -15,34 +15,73 @@ import ( "github.com/stretchr/testify/mock" ) -type imageRepositoryTestFixture struct { - t *testing.T - mockHttpAPI *client.MockHttpAPI - imageRepository imageRepository +type fileRepositoryTestFixture struct { + t *testing.T + mockHttpAPI *client.MockHttpAPI + fileRepository fileRepository } -func newImageRepositoryTestFixture(t *testing.T) *imageRepositoryTestFixture { +func newFileRepositoryTestFixture(t *testing.T) *fileRepositoryTestFixture { mockHttpAPI := client.NewMockHttpAPI(t) - imageRepository := &imageRepository{ + fileRepository := &fileRepository{ uploadthingSecretKey: "test_token_xxx", httpAPI: mockHttpAPI, } - return &imageRepositoryTestFixture{ - t: t, - mockHttpAPI: mockHttpAPI, - imageRepository: *imageRepository, + return &fileRepositoryTestFixture{ + t: t, + mockHttpAPI: mockHttpAPI, + fileRepository: *fileRepository, } } -func TestImageRepository_Upload(t *testing.T) { +func TestNewFileRepository(t *testing.T) { + t.Run("Creates repository with provided httpAPI", func(t *testing.T) { + // Arrange + mockHttpAPI := client.NewMockHttpAPI(t) + cfg := FileRepositoryConfig{ + UploadthingSecretKey: "test_token", + httpAPI: mockHttpAPI, + } + + // Act + repo := NewFileRepository(cfg) + + // Assert + assert.NotNil(t, repo) + concreteRepo, ok := repo.(*fileRepository) + assert.True(t, ok) + assert.Equal(t, "test_token", concreteRepo.uploadthingSecretKey) + assert.Equal(t, mockHttpAPI, concreteRepo.httpAPI) + }) + + t.Run("Creates repository with default httpAPI when nil", func(t *testing.T) { + // Arrange + cfg := FileRepositoryConfig{ + UploadthingSecretKey: "test_token", + httpAPI: nil, + } + + // Act + repo := NewFileRepository(cfg) + + // Assert + assert.NotNil(t, repo) + concreteRepo, ok := repo.(*fileRepository) + assert.True(t, ok) + assert.Equal(t, "test_token", concreteRepo.uploadthingSecretKey) + assert.NotNil(t, concreteRepo.httpAPI) + }) +} + +func TestFileRepository_Upload(t *testing.T) { httpErr := errors.New("http error") - missingFilesErr := errors.New("failed to validate image: files missing") - missingNameErr := errors.New("failed to validate image: file[0]: name missing") - invalidSizeErr := errors.New("failed to validate image: file[0]: size invalid") - missingTypeErr := errors.New("failed to validate image: file[0]: type missing") - invalidACLErr := errors.New("failed to validate image: acl must be 'public-read' or 'private'") - invalidContentDispositionErr := errors.New("failed to validate image: contentDisposition must be 'inline' or 'attachment'") + missingFilesErr := errors.New("failed to validate file: files missing") + missingNameErr := errors.New("failed to validate file: file[0]: name missing") + invalidSizeErr := errors.New("failed to validate file: file[0]: size invalid") + missingTypeErr := errors.New("failed to validate file: file[0]: type missing") + invalidACLErr := errors.New("failed to validate file: acl must be 'public-read' or 'private'") + invalidContentDispositionErr := errors.New("failed to validate file: contentDisposition must be 'inline' or 'attachment'") customID := "custom-123" acl := "public-read" @@ -51,8 +90,8 @@ func TestImageRepository_Upload(t *testing.T) { contentDisposition := "inline" invalidContentDisposition := "invalid-disposition" - validPayload := &domain.ImageUploadRequest{ - Files: []domain.ImageFile{ + validPayload := &domain.FileUploadRequest{ + Files: []domain.FileUpload{ { Name: "test-image.jpg", Size: 1024, @@ -83,12 +122,12 @@ func TestImageRepository_Upload(t *testing.T) { }` type Given struct { - payload *domain.ImageUploadRequest + payload *domain.FileUploadRequest mockUpload func(m *client.MockHttpAPI) } type Expected struct { - data *domain.ImageUploadFile + data *domain.FileUploaded err error } @@ -109,7 +148,7 @@ func TestImageRepository_Upload(t *testing.T) { }, }, expected: Expected{ - data: &domain.ImageUploadFile{ + data: &domain.FileUploaded{ Key: "abc123", URL: "https://utfs.io/f/abc123", FileName: "test-image.jpg", @@ -125,8 +164,8 @@ func TestImageRepository_Upload(t *testing.T) { }, "Successful upload with defaults applied": { given: Given{ - payload: &domain.ImageUploadRequest{ - Files: []domain.ImageFile{ + payload: &domain.FileUploadRequest{ + Files: []domain.FileUpload{ { Name: "test.png", Size: 2048, @@ -144,7 +183,7 @@ func TestImageRepository_Upload(t *testing.T) { }, }, expected: Expected{ - data: &domain.ImageUploadFile{ + data: &domain.FileUploaded{ Key: "abc123", URL: "https://utfs.io/f/abc123", FileName: "test-image.jpg", @@ -160,8 +199,8 @@ func TestImageRepository_Upload(t *testing.T) { }, "Successful upload with private ACL": { given: Given{ - payload: &domain.ImageUploadRequest{ - Files: []domain.ImageFile{ + payload: &domain.FileUploadRequest{ + Files: []domain.FileUpload{ { Name: "private.jpg", Size: 512, @@ -180,7 +219,7 @@ func TestImageRepository_Upload(t *testing.T) { }, }, expected: Expected{ - data: &domain.ImageUploadFile{ + data: &domain.FileUploaded{ Key: "abc123", URL: "https://utfs.io/f/abc123", FileName: "test-image.jpg", @@ -223,7 +262,7 @@ func TestImageRepository_Upload(t *testing.T) { }, expected: Expected{ data: nil, - err: errors.New("failed to upload image: status=400 Bad Request message={\"error\": \"invalid request\"}"), + err: errors.New("failed to upload file: status=400 Bad Request message={\"error\": \"invalid request\"}"), }, }, "Invalid JSON response": { @@ -365,8 +404,8 @@ func TestImageRepository_Upload(t *testing.T) { }, "Validation error: missing files": { given: Given{ - payload: &domain.ImageUploadRequest{ - Files: []domain.ImageFile{}, + payload: &domain.FileUploadRequest{ + Files: []domain.FileUpload{}, }, mockUpload: nil, }, @@ -377,8 +416,8 @@ func TestImageRepository_Upload(t *testing.T) { }, "Validation error: missing file name": { given: Given{ - payload: &domain.ImageUploadRequest{ - Files: []domain.ImageFile{ + payload: &domain.FileUploadRequest{ + Files: []domain.FileUpload{ { Name: "", Size: 1024, @@ -395,8 +434,8 @@ func TestImageRepository_Upload(t *testing.T) { }, "Validation error: invalid file size": { given: Given{ - payload: &domain.ImageUploadRequest{ - Files: []domain.ImageFile{ + payload: &domain.FileUploadRequest{ + Files: []domain.FileUpload{ { Name: "test.jpg", Size: 0, @@ -413,8 +452,8 @@ func TestImageRepository_Upload(t *testing.T) { }, "Validation error: missing file type": { given: Given{ - payload: &domain.ImageUploadRequest{ - Files: []domain.ImageFile{ + payload: &domain.FileUploadRequest{ + Files: []domain.FileUpload{ { Name: "test.jpg", Size: 1024, @@ -431,8 +470,8 @@ func TestImageRepository_Upload(t *testing.T) { }, "Validation error: invalid ACL": { given: Given{ - payload: &domain.ImageUploadRequest{ - Files: []domain.ImageFile{ + payload: &domain.FileUploadRequest{ + Files: []domain.FileUpload{ { Name: "test.jpg", Size: 1024, @@ -450,8 +489,8 @@ func TestImageRepository_Upload(t *testing.T) { }, "Validation error: invalid content disposition": { given: Given{ - payload: &domain.ImageUploadRequest{ - Files: []domain.ImageFile{ + payload: &domain.FileUploadRequest{ + Files: []domain.FileUpload{ { Name: "test.jpg", Size: 1024, @@ -472,13 +511,13 @@ func TestImageRepository_Upload(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { // Arrange - fixture := newImageRepositoryTestFixture(t) + fixture := newFileRepositoryTestFixture(t) if tc.given.mockUpload != nil { tc.given.mockUpload(fixture.mockHttpAPI) } // Act - data, err := fixture.imageRepository.Upload(context.Background(), tc.given.payload) + data, err := fixture.fileRepository.Upload(context.Background(), tc.given.payload) // Assert if tc.expected.err != nil { @@ -496,42 +535,3 @@ func TestImageRepository_Upload(t *testing.T) { }) } } - -func TestNewImageRepository(t *testing.T) { - t.Run("Creates repository with provided httpAPI", func(t *testing.T) { - // Arrange - mockHttpAPI := client.NewMockHttpAPI(t) - cfg := ImageRepositoryConfig{ - UploadthingSecretKey: "test_token", - httpAPI: mockHttpAPI, - } - - // Act - repo := NewImageRepository(cfg) - - // Assert - assert.NotNil(t, repo) - concreteRepo, ok := repo.(*imageRepository) - assert.True(t, ok) - assert.Equal(t, "test_token", concreteRepo.uploadthingSecretKey) - assert.Equal(t, mockHttpAPI, concreteRepo.httpAPI) - }) - - t.Run("Creates repository with default httpAPI when nil", func(t *testing.T) { - // Arrange - cfg := ImageRepositoryConfig{ - UploadthingSecretKey: "test_token", - httpAPI: nil, - } - - // Act - repo := NewImageRepository(cfg) - - // Assert - assert.NotNil(t, repo) - concreteRepo, ok := repo.(*imageRepository) - assert.True(t, ok) - assert.Equal(t, "test_token", concreteRepo.uploadthingSecretKey) - assert.NotNil(t, concreteRepo.httpAPI) - }) -} diff --git a/apps/backend/internal/repository/v1/image.go b/apps/backend/internal/repository/v1/image.go deleted file mode 100644 index 503f3882..00000000 --- a/apps/backend/internal/repository/v1/image.go +++ /dev/null @@ -1,148 +0,0 @@ -package v1 - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" - - "github.com/fingertips18/fingertips18.github.io/backend/internal/client" - "github.com/fingertips18/fingertips18.github.io/backend/internal/domain" -) - -type ImageRepository interface { - Upload(ctx context.Context, image *domain.ImageUploadRequest) (*domain.ImageUploadFile, error) -} - -type ImageRepositoryConfig struct { - UploadthingSecretKey string - - httpAPI client.HttpAPI -} - -type imageRepository struct { - uploadthingSecretKey string - httpAPI client.HttpAPI -} - -// NewImageRepository creates and returns an ImageRepository configured using the -// provided ImageRepositoryConfig. If cfg.httpAPI is nil, a default HTTP API -// client with a 30-second timeout will be created. The returned repository will -// use cfg.UploadthingSecretKey for authenticated requests and the configured -// httpAPI for performing image-related operations. -func NewImageRepository(cfg ImageRepositoryConfig) ImageRepository { - httpAPI := cfg.httpAPI - if httpAPI == nil { - httpAPI = client.NewHTTPAPI(30 * time.Second) - } - - return &imageRepository{ - uploadthingSecretKey: cfg.UploadthingSecretKey, - httpAPI: httpAPI, - } -} - -// Upload uploads the provided UploadthingUploadRequest to the UploadThing service and -// returns the URL of the uploaded file or an error. -// -// Behavior: -// - Validates the incoming request via image.Validate() and returns an error if invalid. -// - Applies default fallback values when not provided (ACL => "public-read", -// ContentDisposition => "inline"). -// - Marshals the request to JSON and issues an HTTP POST to -// "https://api.uploadthing.com/v6/uploadFiles" using the repository's httpAPI and the -// repository's Uploadthing API key (r.uploadthingSecretKey). The HTTP request is executed -// with the provided ctx. -// - Treats any non-200 (OK) response as an error and includes the response status and body -// in the returned error for diagnostics. -// - Decodes the successful response into domain.ImageUploadResponse, validates it, -// and returns the file metadata of the first returned file (uploadResp.Data[0]). -// - All underlying errors are wrapped with context for easier debugging. -// -// Logging: the method logs the upload attempt and the resulting uploaded file URL on success. -// -// Return values: -// - *domain.ImageUploadFile: the file metadata on success, or nil on failure. -// - error: non-nil if validation, marshaling, network, decoding, or response validation fails. -func (r *imageRepository) Upload(ctx context.Context, image *domain.ImageUploadRequest) (*domain.ImageUploadFile, error) { - // Validate request structure - if err := image.Validate(); err != nil { - return nil, fmt.Errorf("failed to validate image: %w", err) - } - - log.Println("Attempting to upload the image...") - - payload := *image - - // Default fallback values - if payload.ACL == nil { - acl := "public-read" - payload.ACL = &acl - } - if payload.ContentDisposition == nil { - contentDisposition := "inline" - payload.ContentDisposition = &contentDisposition - } - - // Marshal request payload - body, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal payload: %w", err) - } - - // Build HTTP request - req, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - "https://api.uploadthing.com/v6/uploadFiles", - bytes.NewBuffer(body), - ) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Uploadthing-Api-Key", r.uploadthingSecretKey) - - // Execute request - resp, err := r.httpAPI.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send HTTP request: %w", err) - } - defer resp.Body.Close() - - // Handle non-200 response - if resp.StatusCode != http.StatusOK { - respBody, readErr := io.ReadAll(resp.Body) - if readErr != nil { - log.Printf("failed to read error response body: %v", readErr) - } - return nil, fmt.Errorf( - "failed to upload image: status=%s message=%s", - resp.Status, - respBody, - ) - } - - // Decode UploadThing success response - var uploadResp domain.ImageUploadResponse - if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { - return nil, fmt.Errorf("failed to decode uploadthing response: %w", err) - } - - // Make sure at least one file was returned - if err := uploadResp.Validate(); err != nil { - return nil, fmt.Errorf("invalid uploadthing response: %w", err) - } - - // Extract file URL - data := uploadResp.Data[0] - - log.Println("Image uploaded successfully:", data.FileName) - - return &data, nil -} diff --git a/apps/backend/internal/repository/v1/mocks/file_repository.go b/apps/backend/internal/repository/v1/mocks/file_repository.go index c6d6a70f..7210cabc 100644 --- a/apps/backend/internal/repository/v1/mocks/file_repository.go +++ b/apps/backend/internal/repository/v1/mocks/file_repository.go @@ -439,3 +439,71 @@ func (_c *MockFileRepository_Update_Call) RunAndReturn(run func(ctx context.Cont _c.Call.Return(run) return _c } + +// Upload provides a mock function for the type MockFileRepository +func (_mock *MockFileRepository) Upload(ctx context.Context, file *domain.FileUploadRequest) (*domain.FileUploaded, error) { + ret := _mock.Called(ctx, file) + + if len(ret) == 0 { + panic("no return value specified for Upload") + } + + var r0 *domain.FileUploaded + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.FileUploadRequest) (*domain.FileUploaded, error)); ok { + return returnFunc(ctx, file) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.FileUploadRequest) *domain.FileUploaded); ok { + r0 = returnFunc(ctx, file) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.FileUploaded) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *domain.FileUploadRequest) error); ok { + r1 = returnFunc(ctx, file) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockFileRepository_Upload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upload' +type MockFileRepository_Upload_Call struct { + *mock.Call +} + +// Upload is a helper method to define mock.On call +// - ctx context.Context +// - file *domain.FileUploadRequest +func (_e *MockFileRepository_Expecter) Upload(ctx interface{}, file interface{}) *MockFileRepository_Upload_Call { + return &MockFileRepository_Upload_Call{Call: _e.mock.On("Upload", ctx, file)} +} + +func (_c *MockFileRepository_Upload_Call) Run(run func(ctx context.Context, file *domain.FileUploadRequest)) *MockFileRepository_Upload_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *domain.FileUploadRequest + if args[1] != nil { + arg1 = args[1].(*domain.FileUploadRequest) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockFileRepository_Upload_Call) Return(fileUploaded *domain.FileUploaded, err error) *MockFileRepository_Upload_Call { + _c.Call.Return(fileUploaded, err) + return _c +} + +func (_c *MockFileRepository_Upload_Call) RunAndReturn(run func(ctx context.Context, file *domain.FileUploadRequest) (*domain.FileUploaded, error)) *MockFileRepository_Upload_Call { + _c.Call.Return(run) + return _c +} diff --git a/apps/backend/internal/repository/v1/mocks/image_repository.go b/apps/backend/internal/repository/v1/mocks/image_repository.go deleted file mode 100644 index 2447986f..00000000 --- a/apps/backend/internal/repository/v1/mocks/image_repository.go +++ /dev/null @@ -1,107 +0,0 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify - -package v1 - -import ( - "context" - - "github.com/fingertips18/fingertips18.github.io/backend/internal/domain" - mock "github.com/stretchr/testify/mock" -) - -// NewMockImageRepository creates a new instance of MockImageRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockImageRepository(t interface { - mock.TestingT - Cleanup(func()) -}) *MockImageRepository { - mock := &MockImageRepository{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockImageRepository is an autogenerated mock type for the ImageRepository type -type MockImageRepository struct { - mock.Mock -} - -type MockImageRepository_Expecter struct { - mock *mock.Mock -} - -func (_m *MockImageRepository) EXPECT() *MockImageRepository_Expecter { - return &MockImageRepository_Expecter{mock: &_m.Mock} -} - -// Upload provides a mock function for the type MockImageRepository -func (_mock *MockImageRepository) Upload(ctx context.Context, image *domain.ImageUploadRequest) (*domain.ImageUploadFile, error) { - ret := _mock.Called(ctx, image) - - if len(ret) == 0 { - panic("no return value specified for Upload") - } - - var r0 *domain.ImageUploadFile - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.ImageUploadRequest) (*domain.ImageUploadFile, error)); ok { - return returnFunc(ctx, image) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.ImageUploadRequest) *domain.ImageUploadFile); ok { - r0 = returnFunc(ctx, image) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*domain.ImageUploadFile) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, *domain.ImageUploadRequest) error); ok { - r1 = returnFunc(ctx, image) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockImageRepository_Upload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upload' -type MockImageRepository_Upload_Call struct { - *mock.Call -} - -// Upload is a helper method to define mock.On call -// - ctx context.Context -// - image *domain.ImageUploadRequest -func (_e *MockImageRepository_Expecter) Upload(ctx interface{}, image interface{}) *MockImageRepository_Upload_Call { - return &MockImageRepository_Upload_Call{Call: _e.mock.On("Upload", ctx, image)} -} - -func (_c *MockImageRepository_Upload_Call) Run(run func(ctx context.Context, image *domain.ImageUploadRequest)) *MockImageRepository_Upload_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 *domain.ImageUploadRequest - if args[1] != nil { - arg1 = args[1].(*domain.ImageUploadRequest) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockImageRepository_Upload_Call) Return(imageUploadFile *domain.ImageUploadFile, err error) *MockImageRepository_Upload_Call { - _c.Call.Return(imageUploadFile, err) - return _c -} - -func (_c *MockImageRepository_Upload_Call) RunAndReturn(run func(ctx context.Context, image *domain.ImageUploadRequest) (*domain.ImageUploadFile, error)) *MockImageRepository_Upload_Call { - _c.Call.Return(run) - return _c -} diff --git a/apps/backend/internal/server/server.go b/apps/backend/internal/server/server.go index 7f80fb74..1072f1fd 100644 --- a/apps/backend/internal/server/server.go +++ b/apps/backend/internal/server/server.go @@ -131,15 +131,10 @@ func createHandlers(cfg Config) []handlerConfig { }, ) - imageHandler := v1.NewImageServiceHandler( - v1.ImageServiceConfig{ - UploadthingSecretKey: cfg.UploadthingSecretKey, - }, - ) - fileHandler := v1.NewFileServiceHandler( v1.FileServiceConfig{ - DatabaseAPI: cfg.DatabaseAPI, + DatabaseAPI: cfg.DatabaseAPI, + UploadthingSecretKey: cfg.UploadthingSecretKey, }, ) @@ -164,10 +159,6 @@ func createHandlers(cfg Config) []handlerConfig { paths: []string{"/skill", "/skill/", "/skills", "/skills/"}, handler: skillHandler, }, - { - paths: []string{"/image", "/image/"}, - handler: imageHandler, - }, { paths: []string{"/file", "/file/", "/files", "/files/"}, handler: fileHandler, From d69e20a3fe31c42ab864c0a8daab36b3f947fe4c Mon Sep 17 00:00:00 2001 From: Fingertips Date: Tue, 20 Jan 2026 21:32:04 +0800 Subject: [PATCH 2/5] Correct image source, validation, and control flow - Fixed broken image rendering by using the preview file URL instead of the project ID as the image source. - Strengthened file validation by checking MIME types with `isValidMimeType` before uploading. - Removed redundant `r.Body.Close()` defer from HTTP handlers. - Renamed variables from `image` to `file` to reflect generic upload behavior. - Enforced UploadThing response contract to avoid silently masking invalid payloads. - Added missing return to prevent switch-case fall-through after handling uploads. --- .../src/pages/project/_components/card.tsx | 2 +- apps/admin/src/types/file.ts | 18 +++++++------ apps/backend/internal/domain/file.go | 3 +++ apps/backend/internal/handler/v1/file.go | 25 +++++++++---------- 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/apps/admin/src/pages/project/_components/card.tsx b/apps/admin/src/pages/project/_components/card.tsx index d63391f3..6eb57e6d 100644 --- a/apps/admin/src/pages/project/_components/card.tsx +++ b/apps/admin/src/pages/project/_components/card.tsx @@ -17,7 +17,7 @@ export function Card({ project }: CardProps) {
{`${project.title} setImageLoaded(true)} diff --git a/apps/admin/src/types/file.ts b/apps/admin/src/types/file.ts index 93998ac0..f8c49169 100644 --- a/apps/admin/src/types/file.ts +++ b/apps/admin/src/types/file.ts @@ -117,14 +117,16 @@ export type FileUpload = { * @throws {Error} If any field value is not a string */ function ensureFields(value: unknown): { [k: string]: string } { - return value && typeof value === 'object' - ? Object.fromEntries( - Object.entries(value).map(([k, v]) => [ - k, - ensureString({ value: v, name: k }), - ]), - ) - : {}; + if (!value || typeof value !== 'object') { + throw new Error("Expected property 'fields' to be an object"); + } + + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [ + k, + ensureString({ value: v, name: k }), + ]), + ); } /** diff --git a/apps/backend/internal/domain/file.go b/apps/backend/internal/domain/file.go index e983800a..d9fe29c0 100644 --- a/apps/backend/internal/domain/file.go +++ b/apps/backend/internal/domain/file.go @@ -202,6 +202,9 @@ func (i FileUploadRequest) Validate() error { if f.Type == "" { return errors.New("file[" + strconv.Itoa(idx) + "]: type missing") } + if err := isValidMimeType(f.Type); err != nil { + return errors.New("file[" + strconv.Itoa(idx) + "]: " + err.Error()) + } } // UploadThing ACL accepts: private, public-read diff --git a/apps/backend/internal/handler/v1/file.go b/apps/backend/internal/handler/v1/file.go index a04a1261..64bb20db 100644 --- a/apps/backend/internal/handler/v1/file.go +++ b/apps/backend/internal/handler/v1/file.go @@ -73,6 +73,7 @@ func (h *fileServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case path == "/file/upload": h.Upload(w, r) + return // GET /files?parent_table=...&parent_id=...&role=... case path == "/files": @@ -489,8 +490,6 @@ func (h *fileServiceHandler) Upload(w http.ResponseWriter, r *http.Request) { return } - defer r.Body.Close() - var req dto.FileUploadRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON in request body", http.StatusBadRequest) @@ -513,7 +512,7 @@ func (h *fileServiceHandler) Upload(w http.ResponseWriter, r *http.Request) { ContentDisposition: req.ContentDisposition, } - image, err := h.fileRepo.Upload(r.Context(), &upload) + uploaded, err := h.fileRepo.Upload(r.Context(), &upload) if err != nil { // The error in the repo is comprehensive enough // Ensure that the first letter is capitalize @@ -527,16 +526,16 @@ func (h *fileServiceHandler) Upload(w http.ResponseWriter, r *http.Request) { } file := dto.FileUploadedDTO{ - Key: image.Key, - FileName: image.FileName, - FileType: image.FileType, - FileUrl: image.FileUrl, - ContentDisposition: image.ContentDisposition, - PollingJwt: image.PollingJwt, - PollingUrl: image.PollingUrl, - CustomId: image.CustomId, - URL: image.URL, - Fields: image.Fields, + Key: uploaded.Key, + FileName: uploaded.FileName, + FileType: uploaded.FileType, + FileUrl: uploaded.FileUrl, + ContentDisposition: uploaded.ContentDisposition, + PollingJwt: uploaded.PollingJwt, + PollingUrl: uploaded.PollingUrl, + CustomId: uploaded.CustomId, + URL: uploaded.URL, + Fields: uploaded.Fields, } resp := dto.FileUploadedResponseDTO{ From bc2f348077310ae1e538376f91ea6f2796f9053a Mon Sep 17 00:00:00 2001 From: Fingertips Date: Tue, 20 Jan 2026 21:44:31 +0800 Subject: [PATCH 3/5] Prevent invalid requests and orphaned data - Avoided empty string image sources by conditionally rendering previews or using a safe placeholder to prevent unintended browser requests. - Removed unsupported `UserTable` from the parent table enum to prevent creating orphaned file records without a backing users table. - Updated JSDoc to match actual behavior where invalid payloads throw instead of returning empty objects. - Added request-level validation for upload payloads so client errors return 400 instead of surfacing as 500s. --- .../admin/src/pages/project/_components/card.tsx | 16 +++++++++------- apps/admin/src/types/file.ts | 3 ++- apps/backend/internal/domain/file.go | 3 +-- apps/backend/internal/handler/v1/file.go | 6 ++++++ 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/admin/src/pages/project/_components/card.tsx b/apps/admin/src/pages/project/_components/card.tsx index 6eb57e6d..18b3b98a 100644 --- a/apps/admin/src/pages/project/_components/card.tsx +++ b/apps/admin/src/pages/project/_components/card.tsx @@ -16,13 +16,15 @@ export function Card({ project }: CardProps) { return (
- {`${project.title} setImageLoaded(true)} - className='absolute object-center object-cover size-full' - /> + {project.previews[0]?.url && ( + {`${project.title} setImageLoaded(true)} + className='absolute object-center object-cover size-full' + /> + )} Date: Tue, 20 Jan 2026 21:56:45 +0800 Subject: [PATCH 4/5] Resolve unsafe cast before validation --- apps/admin/src/types/file.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/admin/src/types/file.ts b/apps/admin/src/types/file.ts index 34338aca..9c744081 100644 --- a/apps/admin/src/types/file.ts +++ b/apps/admin/src/types/file.ts @@ -25,16 +25,20 @@ export type File = { * @throws {Error} If the value is not a string or is not a valid file role */ function ensureRole(value: unknown): 'image' { - const roleValue = ensureString({ + const roleString = ensureString({ value, name: 'role', - }) as (typeof FileRole)[keyof typeof FileRole]; - - if (!Object.values(FileRole).includes(roleValue)) { - throw new Error(`Invalid file role: ${roleValue}`); + }); + + if ( + !Object.values(FileRole).includes( + roleString as (typeof FileRole)[keyof typeof FileRole], + ) + ) { + throw new Error(`Invalid file role: ${roleString}`); } - return roleValue; + return roleString as (typeof FileRole)[keyof typeof FileRole]; } /** From 1902e4bf404d78be1a1b27faf6d903f1505c64b2 Mon Sep 17 00:00:00 2001 From: Fingertips Date: Tue, 20 Jan 2026 22:09:21 +0800 Subject: [PATCH 5/5] Resolve file handler test issue --- apps/backend/internal/handler/v1/file_test.go | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/apps/backend/internal/handler/v1/file_test.go b/apps/backend/internal/handler/v1/file_test.go index a1605a39..99d28e73 100644 --- a/apps/backend/internal/handler/v1/file_test.go +++ b/apps/backend/internal/handler/v1/file_test.go @@ -199,17 +199,10 @@ func TestFileServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockFileRepository) { - m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { - return len(req.Files) == 0 - })). - Return(expectedUploadedFile, nil) - }, }, expected: Expected{ - code: http.StatusAccepted, - body: string(expectedResp), + code: http.StatusBadRequest, + body: "files missing\n", }, }, "with all optional fields": { @@ -428,17 +421,10 @@ func TestFileServiceHandler_Upload(t *testing.T) { b, _ := json.Marshal(req) return string(b) }(), - mockRepo: func(m *mockRepo.MockFileRepository) { - m.EXPECT(). - Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool { - return len(req.Files) == 1 && req.Files[0].Size == 0 - })). - Return(expectedUploadedFile, nil) - }, }, expected: Expected{ - code: http.StatusAccepted, - body: string(expectedResp), + code: http.StatusBadRequest, + body: "file[0]: size invalid\n", }, }, "complex metadata": {