1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(server): create a person with optional values (#7706)

* feat: create person dto

* chore: open api

* fix: e2e

* fix: web usage
This commit is contained in:
Jason Rasmussen 2024-03-07 15:34:57 -05:00 committed by GitHub
parent f1a8e385e9
commit 661409bac7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 153 additions and 138 deletions

View File

@ -5,7 +5,15 @@ import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/activity', () => {
const invalidBirthday = [
// TODO: enable after replacing `@Type(() => Date)`
// { birthDate: 'false', response: 'Invalid date' },
// { birthDate: '123567', response: 'Invalid date },
// { birthDate: 123_567, response: ['Birth date cannot be in the future'] },
{ birthDate: new Date(9999, 0, 0).toISOString(), response: ['Birth date cannot be in the future'] },
];
describe('/person', () => {
let admin: LoginResponseDto;
let visiblePerson: PersonResponseDto;
let hiddenPerson: PersonResponseDto;
@ -14,10 +22,6 @@ describe('/activity', () => {
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
});
beforeEach(async () => {
await utils.resetDatabase(['person']);
[visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([
utils.createPerson(admin.accessToken, {
@ -141,6 +145,41 @@ describe('/activity', () => {
});
});
describe('POST /person', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/person`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not accept invalid birth dates', async () => {
for (const { birthDate, response } of invalidBirthday) {
const { status, body } = await request(app)
.post(`/person`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(response));
}
});
it('should create a person', async () => {
const { status, body } = await request(app)
.post(`/person`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
name: 'New Person',
birthDate: '1990-01-01T05:00:00.000Z',
});
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
name: 'New Person',
birthDate: '1990-01-01T05:00:00.000Z',
});
});
});
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/person/${uuidDto.notFound}`);
@ -164,17 +203,9 @@ describe('/activity', () => {
}
it('should not accept invalid birth dates', async () => {
for (const { birthDate, response } of [
{ birthDate: false, response: 'Not found or no person.write access' },
{ birthDate: 'false', response: ['birthDate must be a Date instance'] },
{
birthDate: '123567',
response: 'Not found or no person.write access',
},
{ birthDate: 123_567, response: 'Not found or no person.write access' },
]) {
for (const { birthDate, response } of invalidBirthday) {
const { status, body } = await request(app)
.put(`/person/${uuidDto.notFound}`)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
@ -192,15 +223,8 @@ describe('/activity', () => {
});
it('should clear a date of birth', async () => {
// TODO ironically this uses the update endpoint to create the person
const person = await utils.createPerson(admin.accessToken, {
birthDate: new Date('1990-01-01').toISOString(),
});
expect(person.birthDate).toBeDefined();
const { status, body } = await request(app)
.put(`/person/${person.id}`)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: null });
expect(status).toBe(200);

View File

@ -5,7 +5,7 @@ import {
CreateAssetDto,
CreateLibraryDto,
CreateUserDto,
PersonUpdateDto,
PersonCreateDto,
SharedLinkCreateDto,
ValidateLibraryDto,
createAlbum,
@ -20,7 +20,6 @@ import {
login,
setAdminOnboarding,
signUpAdmin,
updatePerson,
validate,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
@ -252,16 +251,11 @@ export const utils = {
deleteAssets: (accessToken: string, ids: string[]) =>
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),
createPerson: async (accessToken: string, dto?: PersonUpdateDto) => {
// TODO fix createPerson to accept a body
const person = await createPerson({ headers: asBearerAuth(accessToken) });
createPerson: async (accessToken: string, dto?: PersonCreateDto) => {
const person = await createPerson({ personCreateDto: dto || {} }, { headers: asBearerAuth(accessToken) });
await utils.setPersonThumbnail(person.id);
if (!dto) {
return person;
}
return updatePerson({ id: person.id, personUpdateDto: dto }, { headers: asBearerAuth(accessToken) });
},
createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {

View File

@ -104,6 +104,7 @@ doc/PeopleResponseDto.md
doc/PeopleUpdateDto.md
doc/PeopleUpdateItem.md
doc/PersonApi.md
doc/PersonCreateDto.md
doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md
@ -306,6 +307,7 @@ lib/model/path_type.dart
lib/model/people_response_dto.dart
lib/model/people_update_dto.dart
lib/model/people_update_item.dart
lib/model/person_create_dto.dart
lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart
@ -485,6 +487,7 @@ test/people_response_dto_test.dart
test/people_update_dto_test.dart
test/people_update_item_test.dart
test/person_api_test.dart
test/person_create_dto_test.dart
test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/PersonCreateDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4047,6 +4047,16 @@
"post": {
"operationId": "createPerson",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
@ -8720,6 +8730,25 @@
],
"type": "object"
},
"PersonCreateDto": {
"properties": {
"birthDate": {
"description": "Person date of birth.\nNote: the mobile app cannot currently set the birth date to null.",
"format": "date",
"nullable": true,
"type": "string"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
},
"name": {
"description": "Person name.",
"type": "string"
}
},
"type": "object"
},
"PersonResponseDto": {
"properties": {
"birthDate": {

View File

@ -529,6 +529,15 @@ export type PeopleResponseDto = {
people: PersonResponseDto[];
total: number;
};
export type PersonCreateDto = {
/** Person date of birth.
Note: the mobile app cannot currently set the birth date to null. */
birthDate?: string | null;
/** Person visibility */
isHidden?: boolean;
/** Person name. */
name?: string;
};
export type PeopleUpdateItem = {
/** Person date of birth.
Note: the mobile app cannot currently set the birth date to null. */
@ -2051,14 +2060,17 @@ export function getAllPeople({ withHidden }: {
...opts
}));
}
export function createPerson(opts?: Oazapfts.RequestOpts) {
export function createPerson({ personCreateDto }: {
personCreateDto: PersonCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: PersonResponseDto;
}>("/person", {
}>("/person", oazapfts.json({
...opts,
method: "POST"
}));
method: "POST",
body: personCreateDto
})));
}
export function updatePeople({ peopleUpdateDto }: {
peopleUpdateDto: PeopleUpdateDto;

View File

@ -1,11 +1,11 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, MaxDate, ValidateNested } from 'class-validator';
import { AuthDto } from '../auth';
import { Optional, ValidateUUID, toBoolean } from '../domain.util';
export class PersonUpdateDto {
export class PersonCreateDto {
/**
* Person name.
*/
@ -20,16 +20,10 @@ export class PersonUpdateDto {
@Optional({ nullable: true })
@IsDate()
@Type(() => Date)
@MaxDate(() => new Date(), { message: 'Birth date cannot be in the future' })
@ApiProperty({ format: 'date' })
birthDate?: Date | null;
/**
* Asset is used to get the feature face thumbnail.
*/
@Optional()
@IsString()
featureFaceAssetId?: string;
/**
* Person visibility
*/
@ -38,6 +32,15 @@ export class PersonUpdateDto {
isHidden?: boolean;
}
export class PersonUpdateDto extends PersonCreateDto {
/**
* Asset is used to get the feature face thumbnail.
*/
@Optional()
@IsString()
featureFaceAssetId?: string;
}
export class PeopleUpdateDto {
@IsArray()
@ValidateNested({ each: true })

View File

@ -227,8 +227,7 @@ describe(PersonService.name, () => {
});
it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
BadRequestException,
);
@ -237,20 +236,17 @@ describe(PersonService.name, () => {
});
it("should update a person's name", async () => {
personMock.getById.mockResolvedValue(personStub.noName);
personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it("should update a person's date of birth", async () => {
personMock.getById.mockResolvedValue(personStub.noBirthDate);
personMock.update.mockResolvedValue(personStub.withBirthDate);
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
@ -262,71 +258,24 @@ describe(PersonService.name, () => {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
});
expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it('should throw BadRequestException if birthDate is in the future', async () => {
personMock.getById.mockResolvedValue(personStub.noBirthDate);
personMock.update.mockResolvedValue(personStub.withBirthDate);
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
const futureDate = new Date();
futureDate.setMinutes(futureDate.getMinutes() + 1); // Set birthDate to one minute in the future
await expect(sut.update(authStub.admin, 'person-1', { birthDate: futureDate })).rejects.toThrow(
new BadRequestException('Date of birth cannot be in the future'),
);
});
it('should not throw an error if birthdate is in the past', async () => {
const pastDate = new Date();
pastDate.setMinutes(pastDate.getMinutes() - 1); // Set birthDate to one minute in the past
personMock.getById.mockResolvedValue(personStub.noBirthDate);
personMock.update.mockResolvedValue({ ...personStub.withBirthDate, birthDate: pastDate });
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
const result = await sut.update(authStub.admin, 'person-1', { birthDate: pastDate });
expect(result.birthDate).toEqual(pastDate);
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: pastDate });
});
it('should not throw an error if birthdate is today', async () => {
const today = new Date(); // Set birthDate to now()
personMock.getById.mockResolvedValue(personStub.noBirthDate);
personMock.update.mockResolvedValue({ ...personStub.withBirthDate, birthDate: today });
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
const result = await sut.update(authStub.admin, 'person-1', { birthDate: today });
expect(result.birthDate).toEqual(today);
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: today });
});
it('should update a person visibility', async () => {
personMock.getById.mockResolvedValue(personStub.hidden);
personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it("should update a person's thumbnailPath", async () => {
personMock.getById.mockResolvedValue(personStub.withName);
personMock.update.mockResolvedValue(personStub.withName);
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
@ -336,7 +285,6 @@ describe(PersonService.name, () => {
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id });
expect(personMock.getFacesByIds).toHaveBeenCalledWith([
{
@ -362,12 +310,11 @@ describe(PersonService.name, () => {
describe('updateAll', () => {
it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set());
await expect(
sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }),
).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]);
await expect(sut.updateAll(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] })).resolves.toEqual([
{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false },
]);
expect(personMock.update).not.toHaveBeenCalled();
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
@ -488,10 +435,10 @@ describe(PersonService.name, () => {
describe('createPerson', () => {
it('should create a new person', async () => {
personMock.create.mockResolvedValue(personStub.primaryPerson);
personMock.getFaceById.mockResolvedValue(faceStub.face1);
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson);
await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson);
expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
});
});

View File

@ -36,6 +36,7 @@ import {
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
PersonResponseDto,
PersonSearchDto,
PersonStatisticsResponseDto,
@ -91,10 +92,6 @@ export class PersonService {
};
}
createPerson(auth: AuthDto): Promise<PersonResponseDto> {
return this.repository.create({ ownerId: auth.user.id });
}
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
const person = await this.findOrFail(personId);
@ -199,21 +196,21 @@ export class PersonService {
return assets.map((asset) => mapAsset(asset));
}
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.repository.create({
ownerId: auth.user.id,
name: dto.name,
birthDate: dto.birthDate,
isHidden: dto.isHidden,
});
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
let person = await this.findOrFail(id);
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
// Check if the birthDate is in the future
if (birthDate && new Date(birthDate) > new Date()) {
throw new BadRequestException('Date of birth cannot be in the future');
}
if (name !== undefined || birthDate !== undefined || isHidden !== undefined) {
person = await this.repository.update({ id, name, birthDate, isHidden });
}
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
if (assetId) {
await this.access.requirePermission(auth, Permission.ASSET_READ, assetId);
const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]);
@ -221,14 +218,19 @@ export class PersonService {
throw new BadRequestException('Invalid assetId for feature face');
}
person = await this.repository.update({ id, faceAssetId: face.id });
faceId = face.id;
}
const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
if (assetId) {
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
}
return mapPerson(person);
}
async updatePeople(auth: AuthDto, dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
async updateAll(auth: AuthDto, dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
const results: BulkIdResponseDto[] = [];
for (const person of dto.people) {
try {

View File

@ -6,6 +6,7 @@ import {
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
PersonResponseDto,
PersonSearchDto,
PersonService,
@ -32,22 +33,13 @@ export class PersonController {
}
@Post()
createPerson(@Auth() auth: AuthDto): Promise<PersonResponseDto> {
return this.service.createPerson(auth);
}
@Put(':id/reassign')
reassignFaces(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetFaceUpdateDto,
): Promise<PersonResponseDto[]> {
return this.service.reassignFaces(auth, id, dto);
createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.service.create(auth, dto);
}
@Put()
updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updatePeople(auth, dto);
return this.service.updateAll(auth, dto);
}
@Get(':id')
@ -85,6 +77,15 @@ export class PersonController {
return this.service.getAssets(auth, id);
}
@Put(':id/reassign')
reassignFaces(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetFaceUpdateDto,
): Promise<PersonResponseDto[]> {
return this.service.reassignFaces(auth, id, dto);
}
@Post(':id/merge')
mergePerson(
@Auth() auth: AuthDto,

View File

@ -122,7 +122,7 @@
faceDto: { id: peopleWithFace.id },
});
} else if (selectedPersonToCreate[index]) {
const data = await createPerson();
const data = await createPerson({ personCreateDto: {} });
numberOfPersonToCreate.push(data.id);
await reassignFacesById({
id: data.id,

View File

@ -74,7 +74,7 @@
try {
disableButtons = true;
const data = await createPerson();
const data = await createPerson({ personCreateDto: {} });
await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } });
notificationController.show({