refactor(server): update asset endpoint (#3973)

* refactor(server): update asset

* chore: open api
This commit is contained in:
Jason Rasmussen 2023-09-04 22:25:31 -04:00 committed by GitHub
parent 26bc889f8d
commit 454737ca79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 237 additions and 184 deletions

View file

@ -3417,12 +3417,6 @@ export interface UpdateAssetDto {
* @memberof UpdateAssetDto * @memberof UpdateAssetDto
*/ */
'isFavorite'?: boolean; 'isFavorite'?: boolean;
/**
*
* @type {Array<string>}
* @memberof UpdateAssetDto
*/
'tagIds'?: Array<string>;
} }
/** /**
* *
@ -6299,7 +6293,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
}; };
}, },
/** /**
* Update an asset *
* @param {string} id * @param {string} id
* @param {UpdateAssetDto} updateAssetDto * @param {UpdateAssetDto} updateAssetDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -6778,7 +6772,7 @@ export const AssetApiFp = function(configuration?: Configuration) {
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
* Update an asset *
* @param {string} id * @param {string} id
* @param {UpdateAssetDto} updateAssetDto * @param {UpdateAssetDto} updateAssetDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -7035,7 +7029,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
return localVarFp.serveFile(requestParameters.id, requestParameters.isThumb, requestParameters.isWeb, requestParameters.key, options).then((request) => request(axios, basePath)); return localVarFp.serveFile(requestParameters.id, requestParameters.isThumb, requestParameters.isWeb, requestParameters.key, options).then((request) => request(axios, basePath));
}, },
/** /**
* Update an asset *
* @param {AssetApiUpdateAssetRequest} requestParameters Request parameters. * @param {AssetApiUpdateAssetRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
@ -7952,7 +7946,7 @@ export class AssetApi extends BaseAPI {
} }
/** /**
* Update an asset *
* @param {AssetApiUpdateAssetRequest} requestParameters Request parameters. * @param {AssetApiUpdateAssetRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}

View file

@ -1368,8 +1368,6 @@ Name | Type | Description | Notes
Update an asset
### Example ### Example
```dart ```dart
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';

View file

@ -11,7 +11,6 @@ Name | Type | Description | Notes
**description** | **String** | | [optional] **description** | **String** | | [optional]
**isArchived** | **bool** | | [optional] **isArchived** | **bool** | | [optional]
**isFavorite** | **bool** | | [optional] **isFavorite** | **bool** | | [optional]
**tagIds** | **List<String>** | | [optional] [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -1384,10 +1384,7 @@ class AssetApi {
return null; return null;
} }
/// Update an asset /// Performs an HTTP 'PUT /asset/{id}' operation and returns the [Response].
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters: /// Parameters:
/// ///
/// * [String] id (required): /// * [String] id (required):
@ -1419,8 +1416,6 @@ class AssetApi {
); );
} }
/// Update an asset
///
/// Parameters: /// Parameters:
/// ///
/// * [String] id (required): /// * [String] id (required):

View file

@ -16,7 +16,6 @@ class UpdateAssetDto {
this.description, this.description,
this.isArchived, this.isArchived,
this.isFavorite, this.isFavorite,
this.tagIds = const [],
}); });
/// ///
@ -43,25 +42,21 @@ class UpdateAssetDto {
/// ///
bool? isFavorite; bool? isFavorite;
List<String> tagIds;
@override @override
bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto && bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto &&
other.description == description && other.description == description &&
other.isArchived == isArchived && other.isArchived == isArchived &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite;
other.tagIds == tagIds;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(description == null ? 0 : description!.hashCode) + (description == null ? 0 : description!.hashCode) +
(isArchived == null ? 0 : isArchived!.hashCode) + (isArchived == null ? 0 : isArchived!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode);
(tagIds.hashCode);
@override @override
String toString() => 'UpdateAssetDto[description=$description, isArchived=$isArchived, isFavorite=$isFavorite, tagIds=$tagIds]'; String toString() => 'UpdateAssetDto[description=$description, isArchived=$isArchived, isFavorite=$isFavorite]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -80,7 +75,6 @@ class UpdateAssetDto {
} else { } else {
// json[r'isFavorite'] = null; // json[r'isFavorite'] = null;
} }
json[r'tagIds'] = this.tagIds;
return json; return json;
} }
@ -95,9 +89,6 @@ class UpdateAssetDto {
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
isArchived: mapValueOfType<bool>(json, r'isArchived'), isArchived: mapValueOfType<bool>(json, r'isArchived'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
tagIds: json[r'tagIds'] is List
? (json[r'tagIds'] as List).cast<String>()
: const [],
); );
} }
return null; return null;

View file

@ -144,8 +144,6 @@ void main() {
// TODO // TODO
}); });
// Update an asset
//
//Future<AssetResponseDto> updateAsset(String id, UpdateAssetDto updateAssetDto) async //Future<AssetResponseDto> updateAsset(String id, UpdateAssetDto updateAssetDto) async
test('test updateAsset', () async { test('test updateAsset', () async {
// TODO // TODO

View file

@ -31,11 +31,6 @@ void main() {
// TODO // TODO
}); });
// List<String> tagIds (default value: const [])
test('to test the property `tagIds`', () async {
// TODO
});
}); });

View file

@ -2020,7 +2020,6 @@
}, },
"/asset/{id}": { "/asset/{id}": {
"put": { "put": {
"description": "Update an asset",
"operationId": "updateAsset", "operationId": "updateAsset",
"parameters": [ "parameters": [
{ {
@ -7424,18 +7423,6 @@
}, },
"isFavorite": { "isFavorite": {
"type": "boolean" "type": "boolean"
},
"tagIds": {
"example": [
"bf973405-3f2a-48d2-a687-2ed4167164be",
"dd41870b-5d00-46d2-924e-1d8489a0aa0f",
"fad77c3f-deef-4e7e-9608-14c1aa4e559a"
],
"items": {
"type": "string"
},
"title": "Array of tag IDs to add to the asset",
"type": "array"
} }
}, },
"type": "object" "type": "object"

View file

@ -1,4 +1,4 @@
import { AssetEntity, AssetType } from '@app/infra/entities'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { Paginated, PaginationOptions } from '../domain.util'; import { Paginated, PaginationOptions } from '../domain.util';
export type AssetStats = Record<AssetType, number>; export type AssetStats = Record<AssetType, number>;
@ -86,4 +86,5 @@ export interface IAssetRepository {
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>; getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>; getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>; getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
} }

View file

@ -519,6 +519,30 @@ describe(AssetService.name, () => {
}); });
}); });
describe('update', () => {
it('should require asset write access for the id', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(assetMock.save).not.toHaveBeenCalled();
});
it('should update the asset', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.save.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
});
it('should update the exif description', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.save.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
});
});
describe('updateAll', () => { describe('updateAll', () => {
it('should require asset write access for all ids', async () => { it('should require asset write access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.hasOwnerAccess.mockResolvedValue(false);

View file

@ -24,6 +24,7 @@ import {
MemoryLaneDto, MemoryLaneDto,
TimeBucketAssetDto, TimeBucketAssetDto,
TimeBucketDto, TimeBucketDto,
UpdateAssetDto,
mapStats, mapStats,
} from './dto'; } from './dto';
import { import {
@ -279,6 +280,19 @@ export class AssetService {
return mapStats(stats); return mapStats(stats);
} }
async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);
const { description, ...rest } = dto;
if (description !== undefined) {
await this.assetRepository.upsertExif({ assetId: id, description });
}
const asset = await this.assetRepository.save({ id, ...rest });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [id] } });
return mapAsset(asset);
}
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto) { async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto) {
const { ids, ...options } = dto; const { ids, ...options } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);

View file

@ -1,4 +1,4 @@
import { IsBoolean } from 'class-validator'; import { IsBoolean, IsString } from 'class-validator';
import { Optional } from '../../domain.util'; import { Optional } from '../../domain.util';
import { BulkIdsDto } from '../response-dto'; import { BulkIdsDto } from '../response-dto';
@ -11,3 +11,17 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@IsBoolean() @IsBoolean()
isArchived?: boolean; isArchived?: boolean;
} }
export class UpdateAssetDto {
@Optional()
@IsBoolean()
isFavorite?: boolean;
@Optional()
@IsBoolean()
isArchived?: boolean;
@Optional()
@IsString()
description?: string;
}

View file

@ -1,4 +1,4 @@
import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { AssetEntity } from '@app/infra/entities';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan } from 'typeorm'; import { MoreThan } from 'typeorm';
@ -7,7 +7,6 @@ import { Repository } from 'typeorm/repository/Repository';
import { AssetSearchDto } from './dto/asset-search.dto'; import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto'; import { SearchPropertiesDto } from './dto/search-properties.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
@ -26,7 +25,6 @@ export interface IAssetRepository {
asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'livePhotoVideoId'>, asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'livePhotoVideoId'>,
): Promise<AssetEntity>; ): Promise<AssetEntity>;
remove(asset: AssetEntity): Promise<void>; remove(asset: AssetEntity): Promise<void>;
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>; getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>; getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>; getById(assetId: string): Promise<AssetEntity>;
@ -42,10 +40,7 @@ export const IAssetRepository = 'IAssetRepository';
@Injectable() @Injectable()
export class AssetRepository implements IAssetRepository { export class AssetRepository implements IAssetRepository {
constructor( constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
) {}
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> { getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> {
return this.assetRepository return this.assetRepository
@ -164,40 +159,6 @@ export class AssetRepository implements IAssetRepository {
await this.assetRepository.remove(asset); await this.assetRepository.remove(asset);
} }
/**
* Update asset
*/
async update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
asset.isArchived = dto.isArchived ?? asset.isArchived;
if (asset.exifInfo != null) {
if (dto.description !== undefined) {
asset.exifInfo.description = dto.description;
}
await this.exifRepository.save(asset.exifInfo);
} else {
const exifInfo = new ExifEntity();
if (dto.description !== undefined) {
exifInfo.description = dto.description;
}
exifInfo.asset = asset;
await this.exifRepository.save(exifInfo);
asset.exifInfo = exifInfo;
}
await this.assetRepository.update(asset.id, {
isFavorite: asset.isFavorite,
isArchived: asset.isArchived,
});
return this.assetRepository.findOneOrFail({
where: {
id: asset.id,
},
});
}
/** /**
* Get assets by device's Id on the database * Get assets by device's Id on the database
* @param ownerId * @param ownerId

View file

@ -9,7 +9,6 @@ import {
Param, Param,
ParseFilePipe, ParseFilePipe,
Post, Post,
Put,
Query, Query,
Response, Response,
UploadedFiles, UploadedFiles,
@ -33,7 +32,6 @@ import { DeviceIdDto } from './dto/device-id.dto';
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto'; import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
import { SearchAssetDto } from './dto/search-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto'; import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto'; import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto'; import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
@ -194,18 +192,6 @@ export class AssetController {
return this.assetService.getAssetById(authUser, id); return this.assetService.getAssetById(authUser, id);
} }
/**
* Update an asset
*/
@Put('/:id')
updateAsset(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body(ValidationPipe) dto: UpdateAssetDto,
): Promise<AssetResponseDto> {
return this.assetService.updateAsset(authUser, id, dto);
}
@Delete('/') @Delete('/')
deleteAsset( deleteAsset(
@AuthUser() authUser: AuthUserDto, @AuthUser() authUser: AuthUserDto,

View file

@ -96,7 +96,6 @@ describe('AssetService', () => {
create: jest.fn(), create: jest.fn(),
remove: jest.fn(), remove: jest.fn(),
update: jest.fn(),
getAllByUserId: jest.fn(), getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(), getAllByDeviceId: jest.fn(),
getById: jest.fn(), getById: jest.fn(),

View file

@ -41,7 +41,6 @@ import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-ass
import { SearchAssetDto } from './dto/search-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto'; import { SearchPropertiesDto } from './dto/search-properties.dto';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { import {
AssetBulkUploadCheckResponseDto, AssetBulkUploadCheckResponseDto,
AssetRejectReason, AssetRejectReason,
@ -203,21 +202,6 @@ export class AssetService {
return data; return data;
} }
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, assetId);
const asset = await this._assetRepository.getById(assetId);
if (!asset) {
throw new BadRequestException('Asset not found');
}
const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [assetId] } });
return mapAsset(updatedAsset);
}
async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) { async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) {
await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId); await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId);

View file

@ -1,33 +0,0 @@
import { Optional } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsNotEmpty, IsString } from 'class-validator';
export class UpdateAssetDto {
@Optional()
@IsBoolean()
isFavorite?: boolean;
@Optional()
@IsBoolean()
isArchived?: boolean;
@Optional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ApiProperty({
isArray: true,
type: String,
title: 'Array of tag IDs to add to the asset',
example: [
'bf973405-3f2a-48d2-a687-2ed4167164be',
'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
],
})
tagIds?: string[];
@Optional()
@IsString()
description?: string;
}

View file

@ -1,6 +1,6 @@
import { DomainModule } from '@app/domain'; import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra'; import { InfraModule } from '@app/infra';
import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { AssetEntity } from '@app/infra/entities';
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -35,7 +35,7 @@ import {
// //
DomainModule.register({ imports: [InfraModule] }), DomainModule.register({ imports: [InfraModule] }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
TypeOrmModule.forFeature([AssetEntity, ExifEntity]), TypeOrmModule.forFeature([AssetEntity]),
], ],
controllers: [ controllers: [
AssetController, AssetController,

View file

@ -16,6 +16,7 @@ import {
TimeBucketAssetDto, TimeBucketAssetDto,
TimeBucketDto, TimeBucketDto,
TimeBucketResponseDto, TimeBucketResponseDto,
UpdateAssetDto as UpdateDto,
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
@ -90,4 +91,13 @@ export class AssetController {
updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> { updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(authUser, dto); return this.service.updateAll(authUser, dto);
} }
@Put(':id')
updateAsset(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateDto,
): Promise<AssetResponseDto> {
return this.service.update(authUser, id, dto);
}
} }

View file

@ -3,6 +3,7 @@ import { APIKeyEntity } from './api-key.entity';
import { AssetFaceEntity } from './asset-face.entity'; import { AssetFaceEntity } from './asset-face.entity';
import { AssetEntity } from './asset.entity'; import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity'; import { AuditEntity } from './audit.entity';
import { ExifEntity } from './exif.entity';
import { PartnerEntity } from './partner.entity'; import { PartnerEntity } from './partner.entity';
import { PersonEntity } from './person.entity'; import { PersonEntity } from './person.entity';
import { SharedLinkEntity } from './shared-link.entity'; import { SharedLinkEntity } from './shared-link.entity';
@ -33,6 +34,7 @@ export const databaseEntities = [
AssetEntity, AssetEntity,
AssetFaceEntity, AssetFaceEntity,
AuditEntity, AuditEntity,
ExifEntity,
PartnerEntity, PartnerEntity,
PersonEntity, PersonEntity,
SharedLinkEntity, SharedLinkEntity,

View file

@ -18,7 +18,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm'; import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType } from '../entities'; import { AssetEntity, AssetType, ExifEntity } from '../entities';
import OptionalBetween from '../utils/optional-between.util'; import OptionalBetween from '../utils/optional-between.util';
import { paginate } from '../utils/pagination.util'; import { paginate } from '../utils/pagination.util';
@ -29,7 +29,14 @@ const truncateMap: Record<TimeBucketSize, string> = {
@Injectable() @Injectable()
export class AssetRepository implements IAssetRepository { export class AssetRepository implements IAssetRepository {
constructor(@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>) {} constructor(
@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
) {}
async upsertExif(exif: Partial<ExifEntity>): Promise<void> {
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
}
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]> { getByDate(ownerId: string, date: Date): Promise<AssetEntity[]> {
// For reference of a correct approach although slower // For reference of a correct approach although slower

View file

@ -1,17 +1,11 @@
import { DomainModule } from '@app/domain'; import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra'; import { InfraModule } from '@app/infra';
import { ExifEntity } from '@app/infra/entities';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
@Module({ @Module({
imports: [ imports: [DomainModule.register({ imports: [InfraModule] })],
//
DomainModule.register({ imports: [InfraModule] }),
TypeOrmModule.forFeature([ExifEntity]),
],
providers: [MetadataExtractionProcessor, AppService], providers: [MetadataExtractionProcessor, AppService],
}) })
export class MicroservicesModule {} export class MicroservicesModule {}

View file

@ -18,7 +18,6 @@ import {
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { Inject, Logger } from '@nestjs/common'; import { Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import tz_lookup from '@photostructure/tz-lookup'; import tz_lookup from '@photostructure/tz-lookup';
import { exiftool, Tags } from 'exiftool-vendored'; import { exiftool, Tags } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
@ -26,7 +25,6 @@ import { Duration } from 'luxon';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import sharp from 'sharp'; import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository';
import { promisify } from 'util'; import { promisify } from 'util';
import { parseLatitude, parseLongitude } from '../utils/exif/coordinates'; import { parseLatitude, parseLongitude } from '../utils/exif/coordinates';
import { exifTimeZone, exifToDate } from '../utils/exif/date-time'; import { exifTimeZone, exifToDate } from '../utils/exif/date-time';
@ -65,7 +63,6 @@ export class MetadataExtractionProcessor {
@Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository, @Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
configService: ConfigService, configService: ConfigService,
) { ) {
@ -407,7 +404,7 @@ export class MetadataExtractionProcessor {
} }
} }
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); await this.assetRepository.upsertExif(newExif);
await this.assetRepository.save({ await this.assetRepository.save({
id: asset.id, id: asset.id,
fileCreatedAt: fileCreatedAt || undefined, fileCreatedAt: fileCreatedAt || undefined,
@ -506,7 +503,7 @@ export class MetadataExtractionProcessor {
} }
} }
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); await this.assetRepository.upsertExif(newExif);
await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt }); await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
return true; return true;

View file

@ -0,0 +1,136 @@
import { IAssetRepository, LoginResponseDto } from '@app/domain';
import { AppModule, AssetController } from '@app/immich';
import { AssetEntity, AssetType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { randomBytes } from 'crypto';
import request from 'supertest';
import { errorStub, uuidStub } from '../fixtures';
import { api, db } from '../test-utils';
const user1Dto = {
email: 'user1@immich.app',
password: 'Password123',
firstName: 'User 1',
lastName: 'Test',
};
const user2Dto = {
email: 'user2@immich.app',
password: 'Password123',
firstName: 'User 2',
lastName: 'Test',
};
let assetCount = 0;
const createAsset = (repository: IAssetRepository, loginResponse: LoginResponseDto): Promise<AssetEntity> => {
const id = assetCount++;
return repository.save({
ownerId: loginResponse.userId,
checksum: randomBytes(20),
originalPath: `/tests/test_${id}`,
deviceAssetId: `test_${id}`,
deviceId: 'e2e-test',
fileCreatedAt: new Date(),
fileModifiedAt: new Date(),
type: AssetType.IMAGE,
originalFileName: `test_${id}`,
});
};
describe(`${AssetController.name} (e2e)`, () => {
let app: INestApplication;
let server: any;
let assetRepository: IAssetRepository;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let asset1: AssetEntity;
let asset2: AssetEntity;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = await moduleFixture.createNestApplication().init();
server = app.getHttpServer();
assetRepository = app.get<IAssetRepository>(IAssetRepository);
});
beforeEach(async () => {
await db.reset();
await api.adminSignUp(server);
const admin = await api.adminLogin(server);
await api.userApi.create(server, admin.accessToken, user1Dto);
user1 = await api.login(server, { email: user1Dto.email, password: user1Dto.password });
asset1 = await createAsset(assetRepository, user1);
await api.userApi.create(server, admin.accessToken, user2Dto);
user2 = await api.login(server, { email: user2Dto.email, password: user2Dto.password });
asset2 = await createAsset(assetRepository, user2);
});
afterAll(async () => {
await db.disconnect();
await app.close();
});
describe('PUT /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/asset/:${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.put(`/asset/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
});
it('should require access', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should favorite an asset', async () => {
expect(asset1).toMatchObject({ isFavorite: false });
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(body).toMatchObject({ id: asset1.id, isFavorite: true });
expect(status).toEqual(200);
});
it('should archive an asset', async () => {
expect(asset1).toMatchObject({ isArchived: false });
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isArchived: true });
expect(body).toMatchObject({ id: asset1.id, isArchived: true });
expect(status).toEqual(200);
});
it('should set the description', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'Test asset description' });
expect(body).toMatchObject({
id: asset1.id,
exifInfo: expect.objectContaining({ description: 'Test asset description' }),
});
expect(status).toEqual(200);
});
});
});

View file

@ -24,6 +24,11 @@ export const errorStub = {
statusCode: 400, statusCode: 400,
message: expect.any(Array), message: expect.any(Array),
}, },
noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'),
},
incorrectLogin: { incorrectLogin: {
error: 'Unauthorized', error: 'Unauthorized',
statusCode: 401, statusCode: 401,

View file

@ -2,6 +2,7 @@ import { IAssetRepository } from '@app/domain';
export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => { export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
return { return {
upsertExif: jest.fn(),
getByDate: jest.fn(), getByDate: jest.fn(),
getByIds: jest.fn().mockResolvedValue([]), getByIds: jest.fn().mockResolvedValue([]),
getByAlbumId: jest.fn(), getByAlbumId: jest.fn(),

View file

@ -3417,12 +3417,6 @@ export interface UpdateAssetDto {
* @memberof UpdateAssetDto * @memberof UpdateAssetDto
*/ */
'isFavorite'?: boolean; 'isFavorite'?: boolean;
/**
*
* @type {Array<string>}
* @memberof UpdateAssetDto
*/
'tagIds'?: Array<string>;
} }
/** /**
* *
@ -6299,7 +6293,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
}; };
}, },
/** /**
* Update an asset *
* @param {string} id * @param {string} id
* @param {UpdateAssetDto} updateAssetDto * @param {UpdateAssetDto} updateAssetDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -6778,7 +6772,7 @@ export const AssetApiFp = function(configuration?: Configuration) {
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
* Update an asset *
* @param {string} id * @param {string} id
* @param {UpdateAssetDto} updateAssetDto * @param {UpdateAssetDto} updateAssetDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -7035,7 +7029,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
return localVarFp.serveFile(requestParameters.id, requestParameters.isThumb, requestParameters.isWeb, requestParameters.key, options).then((request) => request(axios, basePath)); return localVarFp.serveFile(requestParameters.id, requestParameters.isThumb, requestParameters.isWeb, requestParameters.key, options).then((request) => request(axios, basePath));
}, },
/** /**
* Update an asset *
* @param {AssetApiUpdateAssetRequest} requestParameters Request parameters. * @param {AssetApiUpdateAssetRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
@ -7952,7 +7946,7 @@ export class AssetApi extends BaseAPI {
} }
/** /**
* Update an asset *
* @param {AssetApiUpdateAssetRequest} requestParameters Request parameters. * @param {AssetApiUpdateAssetRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}