2 Commits

Author SHA1 Message Date
e00de3dd0f Finished implementing FileUploadService + added test workflow
Some checks failed
Setup testing environment and test the code / build (push) Failing after 0s
2026-04-05 11:53:44 +02:00
e390012b1f Finished implementing FileUploadService + added test workflow 2026-04-05 11:50:44 +02:00
12 changed files with 325 additions and 6 deletions

61
.gitea/workflows/test.yml Normal file
View File

@@ -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

3
.gitignore vendored
View File

@@ -9,4 +9,5 @@ yarn-error.log*
.pnp.js
.vscode/*
.idea/*
.idea/*
.DS_Store

16
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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(<RegisterUploadResp>{
uploadId: "MockUploadId"
})
}),
http.post('*/chat/uploadChunk', () => {
return HttpResponse.json()
}),
http.post('*/chat/finishUpload', () => {
return HttpResponse.json()
}),
]

View File

@@ -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
]

View File

@@ -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")
});
})

View File

@@ -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<string> {
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<RegisterUploadResp>("chat/cdnRegisterUpload", <RegisterUploadReq>{
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<GenericErrorBody>(e)) {
throw e;
}
throw new Error("Unexpected error")
}
}
private async finishUpload(roomId: string, userid: string, uploadId: string): Promise<void> {
try {
await this.cdnClient.post("chat/finishUpload", <FinishUploadReq>{
roomId: roomId,
userid: userid,
uploadId: uploadId
});
return
} catch (e) {
console.log(e)
if (isAxiosError<GenericErrorBody>(e)) {
throw e;
}
throw new Error("Unexpected error")
}
}
private async uploadFile(uploadId: string, roomId: string, userid: string, file: FileData, registration: FileUploadRegistration): Promise<void> {
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<void> {
try {
await this.cdnClient.post<RegisterUploadResp>("chat/uploadChunk", <ChunkUploadReq>{
roomId: roomId,
userid: userid,
fileId: fileId,
chunk: chunk,
uploadId: uploadId,
});
return
} catch (e) {
if (isAxiosError<GenericErrorBody>(e)) {
throw e;
}
throw new Error("Unexpected error")
}
}
}

View File

@@ -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(<SDKConfig>{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" })
}
]
)
});
})

View File

@@ -7,5 +7,5 @@ beforeEach(async () => {
apiUrl: "http://localhost:3000"
})
await getClient().post("v2/reset")
await getClient(false).post("v2/reset")
})

View File

@@ -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' },