diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..0fa1780 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,61 @@ +name: Setup testing environment and test the code + +on: + workflow_dispatch: + push: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + env: + REGISTRY: git.chatenium.hu + IMAGE_NAME: chatenium/api + + services: + mongodb: + image: mongo:6.0 + ports: + - 27017:27017 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + + api: + image: git.chatenium.hu/chatenium/api:latest + credentials: + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASS }} + ports: + - 3000:3000 + env: + ENVIRONMENT_TYPE: "testing" + MONGODB_URL: mongodb://mongodb:27017 + REDIS_SERVER_ADDR: redis:6379 + + cdn: + image: git.chatenium.hu/chatenium/cdn:latest + credentials: + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASS }} + ports: + - 4000:4000 + env: + ENVIRONMENT: "testing" + REDIS_SERVER_ADDR: redis:6379 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run Vitest + run: | + npm install + npm test \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c94f7c3..17daf55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@faker-js/faker": "^10.4.0", "axios": "^1.14.0", - "msw": "^2.12.14" + "msw": "^2.12.14", + "uuid": "^13.0.0" }, "devDependencies": { "typescript": "^5.5.3", @@ -1866,6 +1867,19 @@ "url": "https://github.com/sponsors/kettanaito" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", diff --git a/package.json b/package.json index d3396c1..34c685d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "test": "vitest run --project unit", + "test": "vitest run", + "test-unit": "vitest run --project unit", "test-integration": "vitest run --project integration", "test:watch": "vitest" }, @@ -17,6 +18,7 @@ "dependencies": { "@faker-js/faker": "^10.4.0", "axios": "^1.14.0", - "msw": "^2.12.14" + "msw": "^2.12.14", + "uuid": "^13.0.0" } } diff --git a/src/domain/fileUploadService.schema.ts b/src/domain/fileUploadService.schema.ts new file mode 100644 index 0000000..3907675 --- /dev/null +++ b/src/domain/fileUploadService.schema.ts @@ -0,0 +1,40 @@ +// Request schemas +export interface RegisterUploadReq { + roomId: string + userid: string + files: FileUploadRegistration[] +} + +export interface ChunkUploadReq { + uploadId: string + fileId: string + chunk: string + roomId: string + userid: string +} + +export interface FinishUploadReq { + uploadId: string + roomId: string + userid: string +} + +// Response schemas +export interface RegisterUploadResp { + uploadId: string +} + +// Types +export interface FileUploadRegistration { + size: number + type: string + name: string + fileId: string +} + +export interface FileData { + name: string + extension: string + type: string + data: File +} \ No newline at end of file diff --git a/src/mocks/handlers/fUpl.http.ts b/src/mocks/handlers/fUpl.http.ts new file mode 100644 index 0000000..2db2347 --- /dev/null +++ b/src/mocks/handlers/fUpl.http.ts @@ -0,0 +1,19 @@ +import {http, HttpResponse} from "msw"; +import {GetRTCAccessResp} from "../../domain/callService.schema"; +import {RegisterUploadResp} from "../../domain/fileUploadService.schema"; + +export const fileUploadHandlers = [ + http.post('*/chat/cdnRegisterUpload', () => { + return HttpResponse.json({ + uploadId: "MockUploadId" + }) + }), + + http.post('*/chat/uploadChunk', () => { + return HttpResponse.json() + }), + + http.post('*/chat/finishUpload', () => { + return HttpResponse.json() + }), +] \ No newline at end of file diff --git a/src/mocks/index.ts b/src/mocks/index.ts index 67e34a7..9048c96 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -2,10 +2,12 @@ import {networkHandlers} from "./handlers/auth.http"; import {authHandlers} from "./handlers/network.http"; import {pictureHandlers} from "./handlers/picture.http"; import {callHandlers} from "./handlers/call.http"; +import {fileUploadHandlers} from "./handlers/fUpl.http"; export const allHandlers = [ ...authHandlers, ...networkHandlers, ...pictureHandlers, - ...callHandlers + ...callHandlers, + ...fileUploadHandlers ] \ No newline at end of file diff --git a/src/services/fileUploadService.test.ts b/src/services/fileUploadService.test.ts new file mode 100644 index 0000000..9d71017 --- /dev/null +++ b/src/services/fileUploadService.test.ts @@ -0,0 +1,10 @@ +import {describe, expect, it} from "vitest"; +import {FileUploadService} from "./fileUploadService"; + +describe("fileUploadService", () => { + it('should upload files', async () => { + const service = new FileUploadService("", null, null); + const uploadId = await service.uploadFiles("", "", []) + expect(uploadId).toBe("MockUploadId") + }); +}) \ No newline at end of file diff --git a/src/services/fileUploadService.ts b/src/services/fileUploadService.ts new file mode 100644 index 0000000..4153a13 --- /dev/null +++ b/src/services/fileUploadService.ts @@ -0,0 +1,135 @@ +import { + ChunkUploadReq, + FileData, + FileUploadRegistration, FinishUploadReq, + RegisterUploadReq, + RegisterUploadResp +} from "../domain/fileUploadService.schema"; +import {AxiosInstance, isAxiosError} from "axios"; +import {getClient} from "../core/http"; +import {InviteToCallReq} from "../domain/callService.schema"; +import {GenericErrorBody} from "../domain/http.schema"; +import {v4 as uuidv4} from 'uuid'; + +export class FileUploadService { + client: AxiosInstance; + cdnClient: AxiosInstance; + + constructor(token: string, private httpClientOverwrite: AxiosInstance | null, private cdnHttpClientOverwrite: AxiosInstance | null) { + this.client = (httpClientOverwrite ?? getClient(false)).create({ + headers: { + "Authorization": token + } + }) + this.cdnClient = (cdnHttpClientOverwrite ?? getClient(true)).create({ + headers: { + "Authorization": token + } + }) + } + + private MIN_CHUNK_SIZE = 25000000 + + private calculateChunkSize(totalSize: number) { + let chunks = Math.floor(totalSize / this.MIN_CHUNK_SIZE); + if (chunks === 0) return totalSize; + const chunkSize = Math.ceil(totalSize / chunks); + return Math.max(chunkSize, this.MIN_CHUNK_SIZE); + } + + /** + * Automatically registers the upload, calculates chunksize and uploads each chunk and returns with an uploadId that must be provided when sending the message + * @param roomId chatid or channelId + * @param userid + * @param files + */ + async uploadFiles(roomId: string, userid: string, files: FileData[]): Promise { + let registrations: FileUploadRegistration[] = []; + + files.forEach(file => { + registrations.push({ + fileId: uuidv4(), + name: file.name, + type: file.type, + size: file.data.size, + }) + }) + + try { + const resp = await this.client.post("chat/cdnRegisterUpload", { + roomId: roomId, + userid: userid, + files: registrations, + }); + for (let filesUploaded = 0; filesUploaded < files.length; filesUploaded++) { + await this.uploadFile(resp.data.uploadId, roomId, userid, files[filesUploaded], registrations[filesUploaded]) + } + await this.finishUpload(roomId, userid, resp.data.uploadId) + return resp.data.uploadId + } catch (e) { + console.log(e) + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + private async finishUpload(roomId: string, userid: string, uploadId: string): Promise { + try { + await this.cdnClient.post("chat/finishUpload", { + roomId: roomId, + userid: userid, + uploadId: uploadId + }); + return + } catch (e) { + console.log(e) + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } + + private async uploadFile(uploadId: string, roomId: string, userid: string, file: FileData, registration: FileUploadRegistration): Promise { + const chunkSize = this.calculateChunkSize(file.data.size); + const totalChunks = Math.ceil(file.data.size / chunkSize); + + const arrayBuffer = await file.data.arrayBuffer(); + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, file.data.size); + const chunk = new Uint8Array(arrayBuffer.slice(start, end)); + const base64 = this.uint8ToBase64(chunk); + await this.uploadChunk(uploadId, roomId, userid, registration.fileId, base64); + } + } + + private uint8ToBase64(uint8: Uint8Array): string { + let binString = ""; + for (let i = 0; i < uint8.length; i++) { + binString += String.fromCharCode(uint8[i]); + } + return btoa(binString); + } + + private async uploadChunk(uploadId: string, roomId: string, userid: string, fileId: string, chunk: string): Promise { + try { + await this.cdnClient.post("chat/uploadChunk", { + roomId: roomId, + userid: userid, + fileId: fileId, + chunk: chunk, + uploadId: uploadId, + }); + return + } catch (e) { + if (isAxiosError(e)) { + throw e; + } + throw new Error("Unexpected error") + } + } +} \ No newline at end of file diff --git a/tests/fileUploadService.test.ts b/tests/fileUploadService.test.ts new file mode 100644 index 0000000..7ba0b8b --- /dev/null +++ b/tests/fileUploadService.test.ts @@ -0,0 +1,33 @@ +import {describe, it} from "vitest"; +import {FileUploadService} from "../src/services/fileUploadService"; +import {environment, SDKConfig} from "../src/core/environment"; +import {getClient} from "../src/core/http"; +import {FileData} from "../src/domain/fileUploadService.schema"; +import axios from "axios"; + +describe("FileUploadService Integration Testing", () => { + const FILE_UPL_SERVICE_TESTING_USER_ID = "000000000000000000000000" + const FILE_UPL_SERVICE_TESTING_CHAT_ID = "000000000000000000000000" + const FILE_UPL_SERVICE_TESTING_TOKEN = "testingToken" + + environment.overwrite({apiUrl: "http://localhost:3000", cdnUrl: "http://localhost:4000"}) + it('should upload all files', async () => { + const response = await axios.get("https://picsum.photos/500", { + responseType: 'blob' + }); + + const service = new FileUploadService(FILE_UPL_SERVICE_TESTING_TOKEN, getClient(false), getClient(true)); + await service.uploadFiles( + FILE_UPL_SERVICE_TESTING_CHAT_ID, + FILE_UPL_SERVICE_TESTING_USER_ID, + [ + { + name: "filename", + type: "image", + extension: "jpeg", + data: new File([response.data], "filename", { type: "image/jpeg" }) + } + ] + ) + }); +}) \ No newline at end of file diff --git a/tests/vitest.setup.integration.ts b/tests/vitest.setup.integration.ts index 727136f..0721575 100644 --- a/tests/vitest.setup.integration.ts +++ b/tests/vitest.setup.integration.ts @@ -7,5 +7,5 @@ beforeEach(async () => { apiUrl: "http://localhost:3000" }) - await getClient().post("v2/reset") + await getClient(false).post("v2/reset") }) diff --git a/vitest.config.ts b/vitest.config.ts index 52db107..ed2dbaa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + maxWorkers: 1, + minWorkers: 1, // Top-level configuration (global only) coverage: { provider: 'v8' },