listen to upload event in e2e

test resume with real image
This commit is contained in:
mertalev 2025-10-06 21:00:20 -04:00
parent 484b73eb60
commit 6dbcf8b876
No known key found for this signature in database
GPG key ID: DF6ABC77AAD98C95
5 changed files with 130 additions and 52 deletions

View file

@ -1,13 +1,30 @@
import { getMyUser, LoginResponseDto } from '@immich/sdk'; import { getMyUser, LoginResponseDto } from '@immich/sdk';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures'; import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, asBearerAuth, baseUrl, utils } from 'src/utils'; import { app, asBearerAuth, baseUrl, testAssetDir, utils } from 'src/utils';
import { serializeDictionary } from 'structured-headers'; import { serializeDictionary } from 'structured-headers';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
function makeAssetData(overrides?: Partial<Record<string, unknown>>) {
return serializeDictionary({
filename: 'test-image.jpg',
'device-asset-id': 'rufh',
'device-id': 'test',
'file-created-at': new Date('2025-01-02T00:00:00Z').toISOString(),
'file-modified-at': new Date('2025-01-01T00:00:00Z').toISOString(),
'is-favorite': true,
'icloud-id': 'example-icloud-id',
...overrides,
});
}
describe('/upload', () => { describe('/upload', () => {
let websocket: Socket;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user: LoginResponseDto; let user: LoginResponseDto;
let quotaUser: LoginResponseDto; let quotaUser: LoginResponseDto;
@ -18,18 +35,15 @@ describe('/upload', () => {
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false }); admin = await utils.adminSetup({ onboarding: false });
websocket = await utils.connectWebsocket(admin.accessToken);
user = await utils.userSetup(admin.accessToken, createUserDto.user1); user = await utils.userSetup(admin.accessToken, createUserDto.user1);
cancelQuotaUser = await utils.userSetup(admin.accessToken, createUserDto.user2); cancelQuotaUser = await utils.userSetup(admin.accessToken, createUserDto.user2);
quotaUser = await utils.userSetup(admin.accessToken, createUserDto.userQuota); quotaUser = await utils.userSetup(admin.accessToken, createUserDto.userQuota);
assetData = serializeDictionary({ assetData = makeAssetData();
filename: 'test-image.jpg', });
'device-asset-id': 'rufh',
'device-id': 'test', afterAll(() => {
'file-created-at': new Date('2025-01-02T00:00:00Z').toISOString(), utils.disconnectWebsocket(websocket);
'file-modified-at': new Date('2025-01-01T00:00:00Z').toISOString(),
'is-favorite': false,
'icloud-id': 'example-icloud-id',
});
}); });
describe('startUpload', () => { describe('startUpload', () => {
@ -51,32 +65,50 @@ describe('/upload', () => {
}); });
it('should create a complete upload with Upload-Complete: ?1', async () => { it('should create a complete upload with Upload-Complete: ?1', async () => {
const content = randomBytes(1024); const assetData = makeAssetData({ filename: 'el_torcal_rocks.jpg' });
const content = await readFile(join(testAssetDir, 'formats/jpg/el_torcal_rocks.jpg'));
const { status, headers } = await request(app) const { status, headers, body } = await request(app)
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('X-Immich-Asset-Data', assetData) .set('X-Immich-Asset-Data', assetData)
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
.set('Upload-Length', '1024') .set('Upload-Length', content.byteLength.toString())
.send(content); .send(content);
expect(status).toBe(200); expect(status).toBe(200);
expect(headers['upload-complete']).toBe('?1'); expect(headers['upload-complete']).toBe('?1');
expect(body).toEqual(expect.objectContaining({ id: expect.any(String) }));
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: body.id });
const asset = await utils.getAssetInfo(admin.accessToken, body.id);
expect(asset).toEqual(
expect.objectContaining({
id: body.id,
ownerId: admin.userId,
exifInfo: expect.objectContaining({ fileSizeInByte: content.byteLength }),
originalFileName: 'el_torcal_rocks.jpg',
deviceAssetId: 'rufh',
deviceId: 'test',
isFavorite: true,
visibility: 'timeline',
}),
);
}); });
it('should create a complete upload with Upload-Incomplete: ?0 if version is 3', async () => { it('should create a complete upload with Upload-Incomplete: ?0 if version is 3', async () => {
const content = randomBytes(1024); const content = randomBytes(1024);
const { status, headers } = await request(app) const checksum = createHash('sha1').update(content).digest('base64');
const { status, headers, body } = await request(app)
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '3') .set('Upload-Draft-Interop-Version', '3')
.set('X-Immich-Asset-Data', assetData) .set('X-Immich-Asset-Data', assetData)
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) .set('Repr-Digest', `sha=:${checksum}:`)
.set('Upload-Incomplete', '?0') .set('Upload-Incomplete', '?0')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
.set('Upload-Length', '1024') .set('Upload-Length', '1024')
@ -84,6 +116,22 @@ describe('/upload', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(headers['upload-incomplete']).toBe('?0'); expect(headers['upload-incomplete']).toBe('?0');
expect(body).toEqual(expect.objectContaining({ id: expect.any(String) }));
const asset = await utils.getAssetInfo(user.accessToken, body.id);
expect(asset).toEqual(
expect.objectContaining({
id: body.id,
checksum,
ownerId: user.userId,
exifInfo: expect.objectContaining({ fileSizeInByte: content.byteLength }),
originalFileName: 'test-image.jpg',
deviceAssetId: 'rufh',
deviceId: 'test',
isFavorite: true,
visibility: 'timeline',
}),
);
}); });
it('should reject when Upload-Complete: ?1 with mismatching Content-Length and Upload-Length', async () => { it('should reject when Upload-Complete: ?1 with mismatching Content-Length and Upload-Length', async () => {
@ -275,19 +323,26 @@ describe('/upload', () => {
describe('resumeUpload', () => { describe('resumeUpload', () => {
let uploadResource: string; let uploadResource: string;
let chunks: Buffer[]; let chunks: Buffer[];
let checksum: string;
beforeAll(async () => { beforeAll(async () => {
// Create an incomplete upload // Create an incomplete upload
chunks = [randomBytes(750), randomBytes(500), randomBytes(1500)]; const assetData = makeAssetData({ filename: '8bit-sRGB.jxl' });
const fullContent = Buffer.concat(chunks); const fullContent = await readFile(join(testAssetDir, 'formats/jxl/8bit-sRGB.jxl'));
chunks = [
fullContent.subarray(0, 10000),
fullContent.subarray(10000, 100000),
fullContent.subarray(100000, fullContent.length),
];
checksum = createHash('sha1').update(fullContent).digest('base64');
const response = await request(app) const response = await request(app)
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('X-Immich-Asset-Data', assetData) .set('X-Immich-Asset-Data', assetData)
.set('Repr-Digest', `sha=:${createHash('sha1').update(fullContent).digest('base64')}:`) .set('Repr-Digest', `sha=:${checksum}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Upload-Length', '2750') .set('Upload-Length', '1780777')
.send(chunks[0]); .send(chunks[0]);
uploadResource = response.headers['location']; uploadResource = response.headers['location'];
@ -297,10 +352,10 @@ describe('/upload', () => {
const { status, headers } = await request(baseUrl) const { status, headers } = await request(baseUrl)
.patch(uploadResource) .patch(uploadResource)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('Upload-Offset', '1250') .set('Upload-Offset', chunks[0].length.toString())
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'application/partial-upload') .set('Content-Type', 'application/partial-upload')
.send(chunks[2]); .send(chunks[1]);
expect(status).toBe(401); expect(status).toBe(401);
expect(headers['upload-complete']).toBeUndefined(); expect(headers['upload-complete']).toBeUndefined();
@ -309,12 +364,12 @@ describe('/upload', () => {
it("should reject upload to another user's asset", async () => { it("should reject upload to another user's asset", async () => {
const { status, headers } = await request(baseUrl) const { status, headers } = await request(baseUrl)
.patch(uploadResource) .patch(uploadResource)
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('Upload-Offset', '1250') .set('Upload-Offset', chunks[0].length.toString())
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'application/partial-upload') .set('Content-Type', 'application/partial-upload')
.send(chunks[2]); .send(chunks[1]);
expect(status).toBe(404); expect(status).toBe(404);
expect(headers['upload-complete']).toEqual('?0'); expect(headers['upload-complete']).toEqual('?0');
@ -323,9 +378,9 @@ describe('/upload', () => {
it('should append data with correct offset', async () => { it('should append data with correct offset', async () => {
const { status, headers } = await request(baseUrl) const { status, headers } = await request(baseUrl)
.patch(uploadResource) .patch(uploadResource)
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('Upload-Offset', chunks[0].length.toString()) .set('Upload-Offset', '10000')
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Content-Type', 'application/partial-upload') .set('Content-Type', 'application/partial-upload')
.send(chunks[1]); .send(chunks[1]);
@ -335,20 +390,20 @@ describe('/upload', () => {
const headResponse = await request(baseUrl) const headResponse = await request(baseUrl)
.head(uploadResource) .head(uploadResource)
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8'); .set('Upload-Draft-Interop-Version', '8');
expect(headResponse.headers['upload-offset']).toBe('1250'); expect(headResponse.headers['upload-offset']).toBe('100000');
}); });
it('should reject append with different upload length than before', async () => { it('should reject append with different upload length than before', async () => {
const { status, headers, body } = await request(baseUrl) const { status, headers, body } = await request(baseUrl)
.patch(uploadResource) .patch(uploadResource)
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('Upload-Offset', '1250') .set('Upload-Offset', '100000')
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Upload-Length', '1250') .set('Upload-Length', '100000') // should be 1780777
.set('Content-Type', 'application/partial-upload') .set('Content-Type', 'application/partial-upload')
.send(); .send();
@ -361,40 +416,38 @@ describe('/upload', () => {
}); });
it('should reject append with mismatching length', async () => { it('should reject append with mismatching length', async () => {
const wrongOffset = 100;
const { status, headers, body } = await request(baseUrl) const { status, headers, body } = await request(baseUrl)
.patch(uploadResource) .patch(uploadResource)
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('Upload-Offset', wrongOffset.toString()) .set('Upload-Offset', '10000')
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Content-Type', 'application/partial-upload') .set('Content-Type', 'application/partial-upload')
.send(randomBytes(100)); .send(randomBytes(100));
expect(status).toBe(409); expect(status).toBe(409);
expect(headers['upload-offset']).toBe('1250'); expect(headers['upload-offset']).toBe('100000');
expect(headers['content-type']).toBe('application/problem+json; charset=utf-8'); expect(headers['content-type']).toBe('application/problem+json; charset=utf-8');
expect(body).toEqual({ expect(body).toEqual({
type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset', type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset',
title: 'offset from request does not match offset of resource', title: 'offset from request does not match offset of resource',
'expected-offset': 1250, 'expected-offset': 100000,
'provided-offset': wrongOffset, 'provided-offset': 10000,
}); });
}); });
it('should complete upload with Upload-Complete: ?1', async () => { it('should complete upload with Upload-Complete: ?1', async () => {
const headResponse = await request(baseUrl) const headResponse = await request(baseUrl)
.head(uploadResource) .head(uploadResource)
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8'); .set('Upload-Draft-Interop-Version', '8');
const offset = parseInt(headResponse.headers['upload-offset']); const offset = parseInt(headResponse.headers['upload-offset']);
expect(offset).toBe(1250); expect(offset).toBe(100000);
const { status, headers } = await request(baseUrl) const { status, headers, body } = await request(baseUrl)
.patch(uploadResource) .patch(uploadResource)
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('Upload-Offset', offset.toString()) .set('Upload-Offset', offset.toString())
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
@ -403,12 +456,31 @@ describe('/upload', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(headers['upload-complete']).toBe('?1'); expect(headers['upload-complete']).toBe('?1');
const id = uploadResource.replace('/api/upload/', '');
expect(body).toEqual(expect.objectContaining({ id }));
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: body.id });
const asset = await utils.getAssetInfo(admin.accessToken, id);
expect(asset).toEqual(
expect.objectContaining({
id,
checksum,
ownerId: admin.userId,
exifInfo: expect.objectContaining({ fileSizeInByte: 1_780_777 }),
originalFileName: '8bit-sRGB.jxl',
deviceAssetId: 'rufh',
deviceId: 'test',
isFavorite: true,
visibility: 'timeline',
}),
);
}); });
it('should reject append to completed upload', async () => { it('should reject append to completed upload', async () => {
const { status, headers, body } = await request(baseUrl) const { status, headers, body } = await request(baseUrl)
.patch(uploadResource) .patch(uploadResource)
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('Upload-Offset', '2750') .set('Upload-Offset', '2750')
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')

View file

@ -12,10 +12,10 @@ import {
Req, Req,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiHeader, ApiTags } from '@nestjs/swagger'; import { ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto, UploadHeader, UploadOkDto } from 'src/dtos/asset-upload';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto, UploadHeader } from 'src/dtos/upload.dto';
import { ImmichHeader, Permission } from 'src/enum'; import { ImmichHeader, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AssetUploadService } from 'src/services/asset-upload.service'; import { AssetUploadService } from 'src/services/asset-upload.service';
@ -72,6 +72,7 @@ export class AssetUploadController {
@ApiHeader(apiInteropVersion) @ApiHeader(apiInteropVersion)
@ApiHeader(apiUploadComplete) @ApiHeader(apiUploadComplete)
@ApiHeader(apiContentLength) @ApiHeader(apiContentLength)
@ApiOkResponse({ type: UploadOkDto })
startUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response): Promise<void> { startUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response): Promise<void> {
return this.service.startUpload(auth, req, res, validateSyncOrReject(StartUploadDto, req.headers)); return this.service.startUpload(auth, req, res, validateSyncOrReject(StartUploadDto, req.headers));
} }
@ -87,6 +88,7 @@ export class AssetUploadController {
@ApiHeader(apiInteropVersion) @ApiHeader(apiInteropVersion)
@ApiHeader(apiUploadComplete) @ApiHeader(apiUploadComplete)
@ApiHeader(apiContentLength) @ApiHeader(apiContentLength)
@ApiOkResponse({ type: UploadOkDto })
resumeUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Param() { id }: UUIDParamDto) { resumeUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Param() { id }: UUIDParamDto) {
return this.service.resumeUpload(auth, req, res, id, validateSyncOrReject(ResumeUploadDto, req.headers)); return this.service.resumeUpload(auth, req, res, id, validateSyncOrReject(ResumeUploadDto, req.headers));
} }

View file

@ -153,3 +153,7 @@ export class ResumeUploadDto extends BaseUploadHeadersDto {
} }
export class GetUploadStatusDto extends BaseRufhHeadersDto {} export class GetUploadStatusDto extends BaseRufhHeadersDto {}
export class UploadOkDto {
id!: string;
}

View file

@ -1,5 +1,5 @@
import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import { StructuredBoolean } from 'src/dtos/upload.dto'; import { StructuredBoolean } from 'src/dtos/asset-upload';
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { AssetUploadService } from 'src/services/asset-upload.service'; import { AssetUploadService } from 'src/services/asset-upload.service';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';

View file

@ -7,8 +7,8 @@ import { Readable } from 'node:stream';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto } from 'src/dtos/asset-upload';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto } from 'src/dtos/upload.dto';
import { import {
AssetMetadataKey, AssetMetadataKey,
AssetStatus, AssetStatus,
@ -78,7 +78,7 @@ export class AssetUploadService extends BaseService {
} }
this.onComplete(metadata) this.onComplete(metadata)
.then(() => res.status(200).send()) .then(() => res.status(200).send({ id: asset.id }))
.catch((error) => { .catch((error) => {
this.logger.error(`Failed to complete upload for ${asset.id}: ${error.message}`); this.logger.error(`Failed to complete upload for ${asset.id}: ${error.message}`);
res.status(500).send(); res.status(500).send();
@ -141,7 +141,7 @@ export class AssetUploadService extends BaseService {
try { try {
await this.onComplete(metadata); await this.onComplete(metadata);
} finally { } finally {
res.status(200).send(); res.status(200).send({ id });
} }
}); });
await new Promise((resolve) => writeStream.on('close', resolve)); await new Promise((resolve) => writeStream.on('close', resolve));