Merge remote-tracking branch 'origin/main' into chore/pnpm_alt

This commit is contained in:
Min Idzelis
2025-06-28 03:50:28 +00:00
332 changed files with 53395 additions and 3611 deletions
+288 -325
View File
@@ -1,12 +1,13 @@
import { ClassConstructor } from 'class-transformer';
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { Albums, AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum';
import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Memories, Person, Sessions } from 'src/db';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
import { AlbumUserRole, AssetType, AssetVisibility, MemoryType, SourceType, SyncRequestType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
@@ -16,6 +17,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
@@ -24,297 +26,291 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service';
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
import { SyncService } from 'src/services/sync.service';
import { RepositoryInterface } from 'src/types';
import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
import { automock, ServiceOverrides, wait } from 'test/utils';
import { automock, wait } from 'test/utils';
import { Mocked } from 'vitest';
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64');
interface ClassConstructor<T = any> extends Function {
new (...args: any[]): T;
}
// type Repositories = Omit<ServiceOverrides, 'access' | 'telemetry'>;
type RepositoriesTypes = {
activity: ActivityRepository;
album: AlbumRepository;
albumUser: AlbumUserRepository;
asset: AssetRepository;
assetJob: AssetJobRepository;
config: ConfigRepository;
crypto: CryptoRepository;
database: DatabaseRepository;
email: EmailRepository;
job: JobRepository;
user: UserRepository;
logger: LoggingRepository;
memory: MemoryRepository;
notification: NotificationRepository;
partner: PartnerRepository;
person: PersonRepository;
search: SearchRepository;
session: SessionRepository;
sync: SyncRepository;
systemMetadata: SystemMetadataRepository;
versionHistory: VersionHistoryRepository;
};
type RepositoryMocks = { [K in keyof RepositoriesTypes]: Mocked<RepositoryInterface<RepositoriesTypes[K]>> };
type RepositoryOptions = Partial<{ [K in keyof RepositoriesTypes]: 'mock' | 'real' }>;
type ContextRepositoryMocks<R extends RepositoryOptions> = {
[K in keyof RepositoriesTypes as R[K] extends 'mock' ? K : never]: Mocked<RepositoryInterface<RepositoriesTypes[K]>>;
type MediumTestOptions = {
mock: ClassConstructor<any>[];
real: ClassConstructor<any>[];
database: Kysely<DB>;
};
type ContextRepositories<R extends RepositoryOptions> = {
[K in keyof RepositoriesTypes as R[K] extends 'real' ? K : never]: RepositoriesTypes[K];
export const newMediumService = <S extends BaseService>(Service: ClassConstructor<S>, options: MediumTestOptions) => {
const ctx = new MediumTestContext(Service, options);
return { sut: ctx.sut, ctx };
};
export type Context<R extends RepositoryOptions, S extends BaseService> = {
export class MediumTestContext<S extends BaseService = BaseService> {
private repoCache: Record<string, any> = {};
private sutDeps: any[];
sut: S;
mocks: ContextRepositoryMocks<R>;
repos: ContextRepositories<R>;
getRepository<T extends keyof RepositoriesTypes>(key: T): RepositoriesTypes[T];
};
database: Kysely<DB>;
export type SyncTestOptions = {
db: Kysely<DB>;
};
constructor(
Service: ClassConstructor<S>,
private options: MediumTestOptions,
) {
this.sutDeps = this.makeDeps(options);
this.sut = new Service(...this.sutDeps);
this.database = options.database;
}
export const newSyncAuthUser = () => {
const user = mediumFactory.userInsert();
const session = mediumFactory.sessionInsert({ userId: user.id });
private makeDeps(options: MediumTestOptions) {
const deps = BASE_SERVICE_DEPENDENCIES;
const auth = factory.auth({
session,
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
for (const dep of options.mock) {
if (!deps.includes(dep)) {
throw new Error(`Mocked repository ${dep.name} is not a valid dependency`);
}
}
return {
auth,
session,
user,
create: async (db: Kysely<DB>) => {
await new UserRepository(db).create(user);
await new SessionRepository(db).create(session);
},
};
};
for (const dep of options.real) {
if (!deps.includes(dep)) {
throw new Error(`Real repository ${dep.name} is not a valid dependency`);
}
}
return (deps as ClassConstructor<any>[]).map((dep) => {
if (options.real.includes(dep)) {
return this.get(dep);
}
export const newSyncTest = (options: SyncTestOptions) => {
const { sut, mocks, repos, getRepository } = newMediumService(SyncService, {
database: options.db,
repos: {
sync: 'real',
session: 'real',
},
});
if (options.mock.includes(dep)) {
return newMockRepository(dep);
}
});
}
const testSync = async (auth: AuthDto, types: SyncRequestType[]) => {
get<T>(key: ClassConstructor<T>): T {
if (!this.repoCache[key.name]) {
const real = newRealRepository(key, this.options.database);
this.repoCache[key.name] = real;
}
return this.repoCache[key.name];
}
getMock<T, R = Mocked<T>>(key: ClassConstructor<T>): R {
const index = BASE_SERVICE_DEPENDENCIES.indexOf(key as any);
if (index === -1 || !this.options.mock.includes(key)) {
throw new Error(`getMock called with a key that is not a mock: ${key.name}`);
}
return this.sutDeps[index] as R;
}
async newUser(dto: Partial<Insertable<UserTable>> = {}) {
const user = mediumFactory.userInsert(dto);
const result = await this.get(UserRepository).create(user);
return { user, result };
}
async newPartner(dto: { sharedById: string; sharedWithId: string; inTimeline?: boolean }) {
const partner = { inTimeline: true, ...dto };
const result = await this.get(PartnerRepository).create(partner);
return { partner, result };
}
async newAsset(dto: Partial<Insertable<Assets>> = {}) {
const asset = mediumFactory.assetInsert(dto);
const result = await this.get(AssetRepository).create(asset);
return { asset, result };
}
async newMemory(dto: Partial<Insertable<Memories>> = {}) {
const memory = mediumFactory.memoryInsert(dto);
const result = await this.get(MemoryRepository).create(memory, new Set<string>());
return { memory, result };
}
async newMemoryAsset(dto: { memoryId: string; assetId: string }) {
const result = await this.get(MemoryRepository).addAssetIds(dto.memoryId, [dto.assetId]);
return { memoryAsset: dto, result };
}
async newExif(dto: Insertable<Exif>) {
const result = await this.get(AssetRepository).upsertExif(dto);
return { result };
}
async newAlbum(dto: Insertable<Albums>) {
const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, [], []);
return { album, result };
}
async newAlbumAsset(albumAsset: { albumId: string; assetId: string }) {
const result = await this.get(AlbumRepository).addAssetIds(albumAsset.albumId, [albumAsset.assetId]);
return { albumAsset, result };
}
async newAlbumUser(dto: { albumId: string; userId: string; role?: AlbumUserRole }) {
const { albumId, userId, role = AlbumUserRole.EDITOR } = dto;
const result = await this.get(AlbumUserRepository).create({ albumsId: albumId, usersId: userId, role });
return { albumUser: { albumId, userId, role }, result };
}
async newJobStatus(dto: Partial<Insertable<AssetJobStatus>> & { assetId: string }) {
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: dto.assetId });
const result = await this.get(AssetRepository).upsertJobStatus(jobStatus);
return { jobStatus, result };
}
async newPerson(dto: Partial<Insertable<Person>> & { ownerId: string }) {
const person = mediumFactory.personInsert(dto);
const result = await this.get(PersonRepository).create(person);
return { person, result };
}
async newSession(dto: Partial<Insertable<Sessions>> & { userId: string }) {
const session = mediumFactory.sessionInsert(dto);
const result = await this.get(SessionRepository).create(session);
return { session, result };
}
async newSyncAuthUser() {
const { user } = await this.newUser();
const { session } = await this.newSession({ userId: user.id });
const auth = factory.auth({
session,
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
return {
auth,
session,
user,
};
}
}
export class SyncTestContext extends MediumTestContext<SyncService> {
constructor(database: Kysely<DB>) {
super(SyncService, {
database,
real: [SyncRepository, SyncCheckpointRepository, SessionRepository],
mock: [LoggingRepository],
});
}
async syncStream(auth: AuthDto, types: SyncRequestType[]) {
const stream = mediumFactory.syncStream();
// Wait for 2ms to ensure all updates are available and account for setTimeout inaccuracy
await wait(2);
await sut.stream(auth, stream, { types });
await this.sut.stream(auth, stream, { types });
return stream.getResponse();
};
return {
sut,
mocks,
repos,
getRepository,
testSync,
};
};
export const newMediumService = <R extends RepositoryOptions, S extends BaseService>(
Service: ClassConstructor<S>,
options: {
database: Kysely<DB>;
repos: R;
},
): Context<R, S> => {
const repos: Partial<RepositoriesTypes> = {};
const mocks: Partial<RepositoryMocks> = {};
const loggerMock = getRepositoryMock('logger') as Mocked<LoggingRepository>;
loggerMock.setContext.mockImplementation(() => {});
repos.logger = loggerMock;
for (const [_key, type] of Object.entries(options.repos)) {
if (type === 'real') {
const key = _key as keyof RepositoriesTypes;
repos[key] = getRepository(key, options.database) as any;
continue;
}
if (type === 'mock') {
const key = _key as keyof RepositoryMocks;
mocks[key] = getRepositoryMock(key) as any;
continue;
}
}
const makeRepository = <K extends keyof RepositoriesTypes>(key: K) => {
return repos[key] || getRepository(key, options.database);
};
async syncAckAll(auth: AuthDto, response: Array<{ type: string; ack: string }>) {
const acks: Record<string, string> = {};
for (const { type, ack } of response) {
acks[type] = ack;
}
const deps = asDeps({ ...mocks, ...repos } as ServiceOverrides);
const sut = new Service(...deps);
await this.sut.setAcks(auth, { acks: Object.values(acks) });
}
}
return {
sut,
mocks,
repos,
getRepository: makeRepository,
} as Context<R, S>;
};
export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kysely<DB>) => {
const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
switch (key) {
case 'activity': {
return new ActivityRepository(db);
case AccessRepository:
case AlbumRepository:
case AlbumUserRepository:
case ActivityRepository:
case AssetRepository:
case AssetJobRepository:
case MemoryRepository:
case NotificationRepository:
case PartnerRepository:
case PersonRepository:
case SearchRepository:
case SessionRepository:
case SyncRepository:
case SyncCheckpointRepository:
case SystemMetadataRepository:
case UserRepository:
case VersionHistoryRepository: {
return new key(db);
}
case 'album': {
return new AlbumRepository(db);
case ConfigRepository:
case CryptoRepository: {
return new key();
}
case 'albumUser': {
return new AlbumUserRepository(db);
case DatabaseRepository: {
return new key(db, LoggingRepository.create(), new ConfigRepository());
}
case 'asset': {
return new AssetRepository(db);
case EmailRepository: {
return new key(LoggingRepository.create());
}
case 'assetJob': {
return new AssetJobRepository(db);
}
case 'config': {
return new ConfigRepository();
}
case 'crypto': {
return new CryptoRepository();
}
case 'database': {
return new DatabaseRepository(db, LoggingRepository.create(), new ConfigRepository());
}
case 'email': {
return new EmailRepository(LoggingRepository.create());
}
case 'logger': {
return LoggingRepository.create();
}
case 'memory': {
return new MemoryRepository(db);
}
case 'notification': {
return new NotificationRepository(db);
}
case 'partner': {
return new PartnerRepository(db);
}
case 'person': {
return new PersonRepository(db);
}
case 'search': {
return new SearchRepository(db);
}
case 'session': {
return new SessionRepository(db);
}
case 'sync': {
return new SyncRepository(db);
}
case 'systemMetadata': {
return new SystemMetadataRepository(db);
}
case 'user': {
return new UserRepository(db);
}
case 'versionHistory': {
return new VersionHistoryRepository(db);
case LoggingRepository as unknown as ClassConstructor<LoggingRepository>: {
return new key() as unknown as T;
}
default: {
throw new Error(`Invalid repository key: ${key}`);
throw new Error(`Unable to create repository instance for key: ${key?.name || key}`);
}
}
};
const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
const newMockRepository = <T>(key: ClassConstructor<T>) => {
switch (key) {
case 'activity': {
return automock(ActivityRepository) as Mocked<RepositoryInterface<ActivityRepository>>;
case ActivityRepository:
case AlbumRepository:
case AssetRepository:
case AssetJobRepository:
case ConfigRepository:
case CryptoRepository:
case MemoryRepository:
case NotificationRepository:
case PartnerRepository:
case PersonRepository:
case SessionRepository:
case SyncRepository:
case SyncCheckpointRepository:
case SystemMetadataRepository:
case UserRepository:
case VersionHistoryRepository: {
return automock(key);
}
case 'album': {
return automock(AlbumRepository);
}
case 'asset': {
return automock(AssetRepository);
}
case 'assetJob': {
return automock(AssetJobRepository);
}
case 'config': {
return automock(ConfigRepository);
}
case 'crypto': {
return automock(CryptoRepository);
}
case 'database': {
case DatabaseRepository: {
return automock(DatabaseRepository, {
args: [
undefined,
{
setContext: () => {},
},
{ getEnv: () => ({ database: { vectorExtension: '' } }) },
],
args: [undefined, { setContext: () => {} }, { getEnv: () => ({ database: { vectorExtension: '' } }) }],
});
}
case 'email': {
return automock(EmailRepository, {
args: [
{
setContext: () => {},
},
],
});
case EmailRepository: {
return automock(EmailRepository, { args: [{ setContext: () => {} }] });
}
case 'job': {
case EventRepository: {
return automock(EventRepository, { args: [undefined, undefined, { setContext: () => {} }] });
}
case JobRepository: {
return automock(JobRepository, {
args: [
undefined,
@@ -327,45 +323,13 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
});
}
case 'logger': {
case LoggingRepository as unknown as ClassConstructor<T>: {
const configMock = { getEnv: () => ({ noColor: false }) };
return automock(LoggingRepository, { args: [undefined, configMock], strict: false });
}
case 'memory': {
return automock(MemoryRepository);
}
case 'notification': {
return automock(NotificationRepository);
}
case 'partner': {
return automock(PartnerRepository);
}
case 'person': {
return automock(PersonRepository);
}
case 'session': {
return automock(SessionRepository);
}
case 'sync': {
return automock(SyncRepository);
}
case 'systemMetadata': {
return automock(SystemMetadataRepository);
}
case 'user': {
return automock(UserRepository);
}
case 'versionHistory': {
return automock(VersionHistoryRepository);
case StorageRepository: {
return automock(StorageRepository, { args: [{ setContext: () => {} }] });
}
default: {
@@ -374,55 +338,6 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
}
};
export const asDeps = (repositories: ServiceOverrides) => {
return [
repositories.logger || getRepositoryMock('logger'), // logger
repositories.access, // access
repositories.activity || getRepositoryMock('activity'),
repositories.album || getRepositoryMock('album'),
repositories.albumUser,
repositories.apiKey,
repositories.asset || getRepositoryMock('asset'),
repositories.assetJob || getRepositoryMock('assetJob'),
repositories.audit,
repositories.config || getRepositoryMock('config'),
repositories.cron,
repositories.crypto || getRepositoryMock('crypto'),
repositories.database || getRepositoryMock('database'),
repositories.downloadRepository,
repositories.duplicateRepository,
repositories.email || getRepositoryMock('email'),
repositories.event,
repositories.job || getRepositoryMock('job'),
repositories.library,
repositories.machineLearning,
repositories.map,
repositories.media,
repositories.memory || getRepositoryMock('memory'),
repositories.metadata,
repositories.move,
repositories.notification || getRepositoryMock('notification'),
repositories.oauth,
repositories.partner || getRepositoryMock('partner'),
repositories.person || getRepositoryMock('person'),
repositories.process,
repositories.search,
repositories.serverInfo,
repositories.session || getRepositoryMock('session'),
repositories.sharedLink,
repositories.stack,
repositories.storage,
repositories.sync || getRepositoryMock('sync'),
repositories.systemMetadata || getRepositoryMock('systemMetadata'),
repositories.tag,
repositories.telemetry,
repositories.trash,
repositories.user,
repositories.versionHistory || getRepositoryMock('versionHistory'),
repositories.view,
];
};
const assetInsert = (asset: Partial<Insertable<Assets>> = {}) => {
const id = asset.id || newUuid();
const now = newDate();
@@ -532,6 +447,8 @@ const personInsert = (person: Partial<Insertable<Person>> & { ownerId: string })
};
};
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64');
const sessionInsert = ({ id = newUuid(), userId, ...session }: Partial<Insertable<Sessions>> & { userId: string }) => {
const defaults: Insertable<Sessions> = {
id,
@@ -549,15 +466,40 @@ const sessionInsert = ({ id = newUuid(), userId, ...session }: Partial<Insertabl
const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
const id = user.id || newUuid();
const defaults: Insertable<UserTable> = {
const defaults = {
email: `${id}@immich.cloud`,
name: `User ${id}`,
deletedAt: null,
isAdmin: false,
profileImagePath: '',
shouldChangePassword: true,
};
return { ...defaults, ...user, id };
};
const memoryInsert = (memory: Partial<Insertable<Memories>> = {}) => {
const id = memory.id || newUuid();
const date = newDate();
const defaults: Insertable<Memories> = {
id,
createdAt: date,
updatedAt: date,
deletedAt: null,
type: MemoryType.ON_THIS_DAY,
data: { year: 2025 },
showAt: null,
hideAt: null,
seenAt: null,
isSaved: false,
memoryAt: date,
ownerId: memory.ownerId || newUuid(),
};
return { ...defaults, ...memory, id };
};
class CustomWritable extends Writable {
private data = '';
@@ -579,6 +521,24 @@ const syncStream = () => {
return new CustomWritable();
};
const loginDetails = () => {
return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '' };
};
const loginResponse = (): LoginResponseDto => {
const user = userInsert({});
return {
accessToken: 'access-token',
userId: user.id,
userEmail: user.email,
name: user.name,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
shouldChangePassword: user.shouldChangePassword,
isOnboarded: false,
};
};
export const mediumFactory = {
assetInsert,
assetFaceInsert,
@@ -589,4 +549,7 @@ export const mediumFactory = {
sessionInsert,
syncStream,
userInsert,
memoryInsert,
loginDetails,
loginResponse,
};
@@ -1,44 +1,33 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AssetRepository } from 'src/repositories/asset.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetService } from 'src/services/asset.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
describe(AssetService.name, () => {
let defaultDatabase: Kysely<DB>;
let assetRepo: AssetRepository;
let userRepo: UserRepository;
let defaultDatabase: Kysely<DB>;
const createSut = (db?: Kysely<DB>) => {
return newMediumService(AssetService, {
database: db || defaultDatabase,
repos: {
asset: 'real',
},
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
assetRepo = new AssetRepository(defaultDatabase);
userRepo = new UserRepository(defaultDatabase);
const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetService, {
database: db || defaultDatabase,
real: [AssetRepository],
mock: [LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AssetService.name, () => {
describe('getStatistics', () => {
it('should return stats as numbers, not strings', async () => {
const { sut } = createSut();
const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({ ownerId: user.id });
await userRepo.create(user);
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.getStatistics(auth, {})).resolves.toEqual({ images: 1, total: 1, videos: 0 });
});
@@ -1,74 +1,63 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AssetRepository } from 'src/repositories/asset.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { partners_delete_audit } from 'src/schema/functions';
import { mediumFactory } from 'test/medium.factory';
import { BaseService } from 'src/services/base.service';
import { MediumTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
describe('audit', () => {
let defaultDatabase: Kysely<DB>;
let assetRepo: AssetRepository;
let userRepo: UserRepository;
let partnerRepo: PartnerRepository;
let ctx: MediumTestContext;
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
assetRepo = new AssetRepository(defaultDatabase);
userRepo = new UserRepository(defaultDatabase);
partnerRepo = new PartnerRepository(defaultDatabase);
ctx = new MediumTestContext(BaseService, {
database: await getKyselyDB(),
real: [],
mock: [LoggingRepository],
});
});
describe(partners_delete_audit.name, () => {
it('should not cascade user deletes to partners_audit', async () => {
const user1 = mediumFactory.userInsert();
const user2 = mediumFactory.userInsert();
await Promise.all([userRepo.create(user1), userRepo.create(user2)]);
const partnerRepo = ctx.get(PartnerRepository);
const userRepo = ctx.get(UserRepository);
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
await partnerRepo.create({ sharedById: user1.id, sharedWithId: user2.id });
await userRepo.delete(user1, true);
await expect(
defaultDatabase.selectFrom('partners_audit').select(['id']).where('sharedById', '=', user1.id).execute(),
ctx.database.selectFrom('partners_audit').select(['id']).where('sharedById', '=', user1.id).execute(),
).resolves.toHaveLength(0);
});
});
describe('assets_audit', () => {
it('should not cascade user deletes to assets_audit', async () => {
const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({ ownerId: user.id });
await userRepo.create(user);
await assetRepo.create(asset);
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await userRepo.delete(user, true);
await expect(
defaultDatabase.selectFrom('assets_audit').select(['id']).where('assetId', '=', asset.id).execute(),
ctx.database.selectFrom('assets_audit').select(['id']).where('assetId', '=', asset.id).execute(),
).resolves.toHaveLength(0);
});
});
describe('exif', () => {
it('should automatically set updatedAt and updateId when the row is updated', async () => {
const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({ ownerId: user.id });
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await userRepo.create(user);
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const before = await defaultDatabase
const before = await ctx.database
.selectFrom('exif')
.select(['updatedAt', 'updateId'])
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow();
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon 2' });
await ctx.newExif({ assetId: asset.id, make: 'Canon 2' });
const after = await defaultDatabase
const after = await ctx.database
.selectFrom('exif')
.select(['updatedAt', 'updateId'])
.where('assetId', '=', asset.id)
@@ -0,0 +1,163 @@
import { BadRequestException } from '@nestjs/common';
import { hash } from 'bcrypt';
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AuthType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { AuthService } from 'src/services/auth.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AuthService, {
database: db || defaultDatabase,
real: [
AccessRepository,
ConfigRepository,
CryptoRepository,
DatabaseRepository,
SessionRepository,
SystemMetadataRepository,
UserRepository,
],
mock: [LoggingRepository, StorageRepository, EventRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AuthService.name, () => {
describe('adminSignUp', () => {
it(`should sign up the admin`, async () => {
const { sut } = setup();
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
await expect(sut.adminSignUp(dto)).resolves.toEqual(
expect.objectContaining({
id: expect.any(String),
email: dto.email,
name: dto.name,
isAdmin: true,
}),
);
});
it('should not allow a second admin to sign up', async () => {
const { sut, ctx } = setup();
await ctx.newUser({ isAdmin: true });
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
const response = sut.adminSignUp(dto);
await expect(response).rejects.toThrow(BadRequestException);
await expect(response).rejects.toThrow('The server already has an admin');
});
});
describe('login', () => {
it('should reject an incorrect password', async () => {
const { sut, ctx } = setup();
const password = 'password';
const passwordHashed = await hash(password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password: 'wrong-password' };
await expect(sut.login(dto, mediumFactory.loginDetails())).rejects.toThrow('Incorrect email or password');
});
it('should accept a correct password and return a login response', async () => {
const { sut, ctx } = setup();
const password = 'password';
const passwordHashed = await hash(password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password };
await expect(sut.login(dto, mediumFactory.loginDetails())).resolves.toEqual({
accessToken: expect.any(String),
isAdmin: user.isAdmin,
isOnboarded: false,
name: user.name,
profileImagePath: user.profileImagePath,
userId: user.id,
userEmail: user.email,
shouldChangePassword: user.shouldChangePassword,
});
});
});
describe('logout', () => {
it('should logout', async () => {
const { sut } = setup();
const auth = factory.auth();
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
it('should cleanup the session', async () => {
const { sut, ctx } = setup();
const sessionRepo = ctx.get(SessionRepository);
const eventRepo = ctx.getMock(EventRepository);
const { user } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user.id });
const auth = factory.auth({ session, user });
eventRepo.emit.mockResolvedValue();
await expect(sessionRepo.get(session.id)).resolves.toEqual(expect.objectContaining({ id: session.id }));
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
await expect(sessionRepo.get(session.id)).resolves.toBeUndefined();
});
});
describe('changePassword', () => {
it('should change the password and login with it', async () => {
const { sut, ctx } = setup();
const dto = { password: 'password', newPassword: 'new-password' };
const passwordHashed = await hash(dto.password, 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const auth = factory.auth({ user });
const response = await sut.changePassword(auth, dto);
expect(response).toEqual(
expect.objectContaining({
id: user.id,
email: user.email,
}),
);
expect((response as any).password).not.toBeDefined();
await expect(
sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails()),
).resolves.toBeDefined();
});
it('should validate the current password', async () => {
const { sut, ctx } = setup();
const dto = { password: 'wrong-password', newPassword: 'new-password' };
const passwordHashed = await hash('password', 10);
const { user } = await ctx.newUser({ password: passwordHashed });
const auth = factory.auth({ user });
const response = sut.changePassword(auth, dto);
await expect(response).rejects.toThrow(BadRequestException);
await expect(response).rejects.toThrow('Wrong password');
});
});
});
@@ -1,72 +1,138 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { DB } from 'src/db';
import { AssetFileType } from 'src/enum';
import { AssetFileType, MemoryType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { MemoryService } from 'src/services/memory.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(MemoryService, {
database: db || defaultDatabase,
real: [
AccessRepository,
AssetRepository,
DatabaseRepository,
MemoryRepository,
UserRepository,
SystemMetadataRepository,
UserRepository,
PartnerRepository,
],
mock: [LoggingRepository],
});
};
describe(MemoryService.name, () => {
let defaultDatabase: Kysely<DB>;
const createSut = (db?: Kysely<DB>) => {
return newMediumService(MemoryService, {
database: db || defaultDatabase,
repos: {
asset: 'real',
database: 'real',
memory: 'real',
user: 'real',
systemMetadata: 'real',
partner: 'real',
},
});
};
beforeEach(async () => {
defaultDatabase = await getKyselyDB();
const userRepo = new UserRepository(defaultDatabase);
const admin = mediumFactory.userInsert({ isAdmin: true });
await userRepo.create(admin);
});
describe('create', () => {
it('should create a new memory', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const dto = {
type: MemoryType.ON_THIS_DAY,
data: { year: 2021 },
memoryAt: new Date(2021),
};
await expect(sut.create(auth, dto)).resolves.toEqual({
id: expect.any(String),
type: dto.type,
data: dto.data,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
isSaved: false,
memoryAt: dto.memoryAt,
ownerId: user.id,
assets: [],
});
});
it('should create a new memory (with assets)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const auth = factory.auth({ user });
const dto = {
type: MemoryType.ON_THIS_DAY,
data: { year: 2021 },
memoryAt: new Date(2021),
assetIds: [asset1.id, asset2.id],
};
await expect(sut.create(auth, dto)).resolves.toEqual(
expect.objectContaining({
id: expect.any(String),
assets: [expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })],
}),
);
});
it('should create a new memory and ignore assets the user does not have access to', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user1.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
const auth = factory.auth({ user: user1 });
const dto = {
type: MemoryType.ON_THIS_DAY,
data: { year: 2021 },
memoryAt: new Date(2021),
assetIds: [asset1.id, asset2.id],
};
await expect(sut.create(auth, dto)).resolves.toEqual(
expect.objectContaining({
id: expect.any(String),
assets: [expect.objectContaining({ id: asset1.id })],
}),
);
});
});
describe('onMemoryCreate', () => {
it('should work on an empty database', async () => {
const { sut } = createSut();
const { sut } = setup();
await expect(sut.onMemoriesCreate()).resolves.not.toThrow();
});
it('should create a memory from an asset', async () => {
const { sut, repos, getRepository } = createSut();
const { sut, ctx } = setup();
const assetRepo = ctx.get(AssetRepository);
const memoryRepo = ctx.get(MemoryRepository);
const now = DateTime.fromObject({ year: 2025, month: 2, day: 25 }, { zone: 'utc' }) as DateTime<true>;
const user = mediumFactory.userInsert();
const asset = mediumFactory.assetInsert({
ownerId: user.id,
localDateTime: now.minus({ years: 1 }).toISO(),
});
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: asset.id });
const userRepo = getRepository('user');
const assetRepo = getRepository('asset');
await userRepo.create(user);
await assetRepo.create(asset);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: now.minus({ years: 1 }).toISO() });
await Promise.all([
assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }),
ctx.newExif({ assetId: asset.id, make: 'Canon' }),
ctx.newJobStatus({ assetId: asset.id }),
assetRepo.upsertFiles([
{ assetId: asset.id, type: AssetFileType.PREVIEW, path: '/path/to/preview.jpg' },
{ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: '/path/to/thumbnail.jpg' },
]),
assetRepo.upsertJobStatus(jobStatus),
]);
vi.setSystemTime(now.toJSDate());
await sut.onMemoriesCreate();
const memories = await repos.memory.search(user.id, {});
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
expect(memories[0]).toEqual(
expect.objectContaining({
@@ -88,16 +154,11 @@ describe(MemoryService.name, () => {
});
it('should not generate a memory twice for the same day', async () => {
const { sut, repos, getRepository } = createSut();
const { sut, ctx } = setup();
const assetRepo = ctx.get(AssetRepository);
const memoryRepo = ctx.get(MemoryRepository);
const now = DateTime.fromObject({ year: 2025, month: 2, day: 20 }, { zone: 'utc' }) as DateTime<true>;
const assetRepo = getRepository('asset');
const memoryRepo = getRepository('memory');
const user = mediumFactory.userInsert();
await repos.user.create(user);
const { user } = await ctx.newUser();
for (const dto of [
{
ownerId: user.id,
@@ -112,11 +173,10 @@ describe(MemoryService.name, () => {
localDateTime: now.minus({ year: 1 }).plus({ days: 5 }).toISO(),
},
]) {
const asset = mediumFactory.assetInsert(dto);
await assetRepo.create(asset);
const { asset } = await ctx.newAsset(dto);
await Promise.all([
assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }),
assetRepo.upsertJobStatus(mediumFactory.assetJobStatusInsert({ assetId: asset.id })),
ctx.newExif({ assetId: asset.id, make: 'Canon' }),
ctx.newJobStatus({ assetId: asset.id }),
assetRepo.upsertFiles([
{ assetId: asset.id, type: AssetFileType.PREVIEW, path: '/path/to/preview.jpg' },
{ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: '/path/to/thumbnail.jpg' },
@@ -125,13 +185,13 @@ describe(MemoryService.name, () => {
}
vi.setSystemTime(now.toJSDate());
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
await sut.onMemoriesCreate();
const memoriesAfter = await memoryRepo.search(user.id, {});
expect(memoriesAfter.length).toBe(1);
});
@@ -139,7 +199,7 @@ describe(MemoryService.name, () => {
describe('onMemoriesCleanup', () => {
it('should run without error', async () => {
const { sut } = createSut();
const { sut } = setup();
await expect(sut.onMemoriesCleanup()).resolves.not.toThrow();
});
});
@@ -0,0 +1,80 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AccessRepository } from 'src/repositories/access.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { PersonService } from 'src/services/person.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(PersonService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, PersonRepository],
mock: [LoggingRepository, StorageRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(PersonService.name, () => {
describe('delete', () => {
it('should throw an error when there is no access', async () => {
const { sut } = setup();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.delete(auth, personId)).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
const { sut, ctx } = setup();
const personRepo = ctx.get(PersonRepository);
const storageMock = ctx.getMock(StorageRepository);
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const auth = factory.auth({ user });
storageMock.unlink.mockResolvedValue();
await expect(personRepo.getById(person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
await expect(sut.delete(auth, person.id)).resolves.toBeUndefined();
await expect(personRepo.getById(person.id)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath);
});
});
describe('deleteAll', () => {
it('should throw an error when there is no access', async () => {
const { sut } = setup();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.deleteAll(auth, { ids: [personId] })).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
const { sut, ctx } = setup();
const storageMock = ctx.getMock(StorageRepository);
const personRepo = ctx.get(PersonRepository);
const { user } = await ctx.newUser();
const { person: person1 } = await ctx.newPerson({ ownerId: user.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user.id });
const auth = factory.auth({ user });
storageMock.unlink.mockResolvedValue();
await expect(sut.deleteAll(auth, { ids: [person1.id, person2.id] })).resolves.toBeUndefined();
await expect(personRepo.getById(person1.id)).resolves.toBeUndefined();
await expect(personRepo.getById(person2.id)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath);
expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath);
});
});
});
@@ -2,72 +2,65 @@ import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { DB } from 'src/db';
import { ImmichEnvironment, JobName, JobStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { UserService } from 'src/services/user.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
describe(UserService.name, () => {
let defaultDatabase: Kysely<DB>;
let defaultDatabase: Kysely<DB>;
const createSut = (db?: Kysely<DB>) => {
process.env.IMMICH_ENV = ImmichEnvironment.TESTING;
const setup = (db?: Kysely<DB>) => {
process.env.IMMICH_ENV = ImmichEnvironment.TESTING;
return newMediumService(UserService, {
database: db || defaultDatabase,
repos: {
user: 'real',
crypto: 'real',
config: 'real',
job: 'mock',
systemMetadata: 'real',
},
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
const { repos } = createSut();
await repos.user.create({ isAdmin: true, email: 'admin@immich.cloud' });
return newMediumService(UserService, {
database: db || defaultDatabase,
real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository],
mock: [LoggingRepository, JobRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
const { ctx } = setup();
await ctx.newUser({ isAdmin: true, email: 'admin@immich.cloud' });
});
describe(UserService.name, () => {
describe('create', () => {
it('should create a user', async () => {
const { sut } = createSut();
const { sut } = setup();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ name: user.name, email: user.email })).resolves.toEqual(
expect.objectContaining({ name: user.name, email: user.email }),
);
});
it('should reject user with duplicate email', async () => {
const { sut } = createSut();
const { sut } = setup();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
});
it('should not return password', async () => {
const { sut } = createSut();
const { sut } = setup();
const dto = mediumFactory.userInsert({ password: 'password' });
const user = await sut.createUser({ email: dto.email, password: 'password' });
expect((user as any).password).toBeUndefined();
});
});
describe('search', () => {
it('should get users', async () => {
const { sut, repos } = createSut();
const user1 = mediumFactory.userInsert();
const user2 = mediumFactory.userInsert();
await Promise.all([repos.user.create(user1), repos.user.create(user2)]);
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth = factory.auth({ user: user1 });
await expect(sut.search(auth)).resolves.toEqual(
@@ -81,10 +74,8 @@ describe(UserService.name, () => {
describe('get', () => {
it('should get a user', async () => {
const { sut, repos } = createSut();
const user = mediumFactory.userInsert();
await repos.user.create(user);
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(sut.get(user.id)).resolves.toEqual(
expect.objectContaining({
@@ -96,11 +87,8 @@ describe(UserService.name, () => {
});
it('should not return password', async () => {
const { sut, repos } = createSut();
const user = mediumFactory.userInsert();
await repos.user.create(user);
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const result = await sut.get(user.id);
expect((result as any).password).toBeUndefined();
@@ -109,10 +97,9 @@ describe(UserService.name, () => {
describe('updateMe', () => {
it('should update a user', async () => {
const { sut, repos: repositories } = createSut();
const before = await repositories.user.create(mediumFactory.userInsert());
const auth = factory.auth({ user: { id: before.id } });
const { sut, ctx } = setup();
const { user, result: before } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const after = await sut.updateMe(auth, { name: `${before.name} Updated` });
expect(before.updatedAt).toBeDefined();
@@ -128,17 +115,13 @@ describe(UserService.name, () => {
activationKey:
'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw',
};
const { sut, repos } = createSut();
const user = mediumFactory.userInsert();
await repos.user.create(user);
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.getLicense(auth)).rejects.toThrowError();
const after = await sut.setLicense(auth, license);
expect(after.licenseKey).toEqual(license.licenseKey);
expect(after.activationKey).toEqual(license.activationKey);
const getResponse = await sut.getLicense(auth);
expect(getResponse).toEqual(after);
});
@@ -146,7 +129,7 @@ describe(UserService.name, () => {
describe.sequential('handleUserDeleteCheck', () => {
beforeEach(async () => {
const { sut } = createSut();
const { sut } = setup();
// These tests specifically have to be sequential otherwise we hit race conditions with config changes applying in incorrect tests
const config = await sut.getConfig({ withCache: false });
config.user.deleteDelay = 7;
@@ -154,52 +137,43 @@ describe(UserService.name, () => {
});
it('should work when there are no deleted users', async () => {
const { sut, mocks } = createSut();
mocks.job.queueAll.mockResolvedValue(void 0);
const { sut, ctx } = setup();
const jobMock = ctx.getMock(JobRepository);
jobMock.queueAll.mockResolvedValue(void 0);
await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledExactlyOnceWith([]);
expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]);
});
it('should work when there is a user to delete', async () => {
const { sut, repos, mocks } = createSut(await getKyselyDB());
mocks.job.queueAll.mockResolvedValue(void 0);
const user = mediumFactory.userInsert({ deletedAt: DateTime.now().minus({ days: 60 }).toJSDate() });
await repos.user.create(user);
const { sut, ctx } = setup(await getKyselyDB());
const jobMock = ctx.getMock(JobRepository);
const { user } = await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 60 }).toJSDate() });
jobMock.queueAll.mockResolvedValue(void 0);
await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledExactlyOnceWith([
expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([
{ name: JobName.USER_DELETION, data: { id: user.id } },
]);
});
it('should skip a recently deleted user', async () => {
const { sut, repos, mocks } = createSut(await getKyselyDB());
mocks.job.queueAll.mockResolvedValue(void 0);
const user = mediumFactory.userInsert({ deletedAt: DateTime.now().minus({ days: 5 }).toJSDate() });
await repos.user.create(user);
const { sut, ctx } = setup(await getKyselyDB());
const jobMock = ctx.getMock(JobRepository);
await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 5 }).toJSDate() });
jobMock.queueAll.mockResolvedValue(void 0);
await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledExactlyOnceWith([]);
expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]);
});
it('should respect a custom user delete delay', async () => {
const { sut, repos, mocks } = createSut(await getKyselyDB());
mocks.job.queueAll.mockResolvedValue(void 0);
const user = mediumFactory.userInsert({ deletedAt: DateTime.now().minus({ days: 25 }).toJSDate() });
await repos.user.create(user);
const { sut, ctx } = setup(await getKyselyDB());
const jobMock = ctx.getMock(JobRepository);
await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 25 }).toJSDate() });
jobMock.queueAll.mockResolvedValue(void 0);
const config = await sut.getConfig({ withCache: false });
config.user.deleteDelay = 30;
await sut.updateConfig(config);
await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.job.queueAll).toHaveBeenCalledExactlyOnceWith([]);
expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]);
});
});
});
@@ -2,38 +2,40 @@ import { Kysely } from 'kysely';
import { serverVersion } from 'src/constants';
import { DB } from 'src/db';
import { JobName } from 'src/enum';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VersionService } from 'src/services/version.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
describe(VersionService.name, () => {
let defaultDatabase: Kysely<DB>;
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(VersionService, {
database: db || defaultDatabase,
repos: {
job: 'mock',
database: 'real',
versionHistory: 'real',
},
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
const setup = (db?: Kysely<DB>) => {
return newMediumService(VersionService, {
database: db || defaultDatabase,
real: [DatabaseRepository, VersionHistoryRepository],
mock: [LoggingRepository, JobRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(VersionService.name, () => {
describe('onBootstrap', () => {
it('record the current version on startup', async () => {
const { sut, repos } = setup();
const { sut, ctx } = setup();
const versionHistoryRepo = ctx.get(VersionHistoryRepository);
const itemsBefore = await repos.versionHistory.getAll();
const itemsBefore = await versionHistoryRepo.getAll();
expect(itemsBefore).toHaveLength(0);
await sut.onBootstrap();
const itemsAfter = await repos.versionHistory.getAll();
const itemsAfter = await versionHistoryRepo.getAll();
expect(itemsAfter).toHaveLength(1);
expect(itemsAfter[0]).toEqual({
createdAt: expect.any(Date),
@@ -43,22 +45,26 @@ describe(VersionService.name, () => {
});
it('should queue memory creation when upgrading from 1.128.0', async () => {
const { sut, repos, mocks } = setup();
mocks.job.queue.mockResolvedValue(void 0);
const { sut, ctx } = setup();
const jobMock = ctx.getMock(JobRepository);
const versionHistoryRepo = ctx.get(VersionHistoryRepository);
jobMock.queue.mockResolvedValue(void 0);
await repos.versionHistory.create({ version: 'v1.128.0' });
await versionHistoryRepo.create({ version: 'v1.128.0' });
await sut.onBootstrap();
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.MEMORIES_CREATE });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.MEMORIES_CREATE });
});
it('should not queue memory creation when upgrading from 1.129.0', async () => {
const { sut, repos, mocks } = setup();
const { sut, ctx } = setup();
const jobMock = ctx.getMock(JobRepository);
const versionHistoryRepo = ctx.get(VersionHistoryRepository);
await repos.versionHistory.create({ version: 'v1.129.0' });
await versionHistoryRepo.create({ version: 'v1.129.0' });
await sut.onBootstrap();
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,180 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncRequestType.AlbumAssetExifsV1, () => {
it('should detect and sync the first album asset exif', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
type: SyncEntityType.AlbumAssetExifV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([]);
});
it('should sync album asset exif for own user', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toHaveLength(1);
});
it('should not sync album asset exif for unrelated user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.EDITOR });
const { session } = await ctx.newSession({ userId: user3.id });
const authUser3 = factory.auth({ session, user: user3 });
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toHaveLength(0);
});
it('should backfill album assets exif when a user shares an album with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: asset1Owner } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset1Owner.id, make: 'asset1Owner' });
await wait(2);
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset1User2.id, make: 'asset1User2' });
await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset2User2.id, make: 'asset2User2' });
await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset3User2.id, make: 'asset3User2' });
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetExifV1,
},
]);
// ack initial album asset exif sync
await ctx.syncAckAll(auth, response);
// create a second album
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await Promise.all(
[asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id].map((assetId) =>
ctx.newAlbumAsset({ albumId: album2.id, assetId }),
),
);
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album user
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset1Owner.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset1User2.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset3User2.id,
}),
type: SyncEntityType.AlbumAssetExifV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([]);
});
});
@@ -0,0 +1,177 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncRequestType.AlbumAssetsV1, () => {
it('should detect and sync the first album asset', async () => {
const originalFileName = 'firstAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({
originalFileName,
ownerId: user2.id,
checksum: Buffer.from(checksum, 'base64'),
thumbhash: Buffer.from(thumbhash, 'base64'),
fileCreatedAt: date,
fileModifiedAt: date,
localDateTime: date,
deletedAt: null,
duration: '0:10:00.00000',
});
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: asset.id,
originalFileName,
ownerId: asset.ownerId,
thumbhash,
checksum,
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
type: SyncEntityType.AlbumAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]);
});
it('should sync album asset for own user', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(1);
});
it('should not sync album asset for unrelated user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user3.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.EDITOR });
const { session } = await ctx.newSession({ userId: user3.id });
const authUser3 = factory.auth({ session, user: user3 });
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(0);
});
it('should backfill album assets when a user shares an album with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: asset1Owner } = await ctx.newAsset({ ownerId: auth.user.id });
await wait(2);
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetV1,
},
]);
// ack initial album asset sync
await ctx.syncAckAll(auth, response);
// create a second album
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await Promise.all(
[asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id].map((assetId) =>
ctx.newAlbumAsset({ albumId: album2.id, assetId }),
),
);
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album user
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset1Owner.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset1User2.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset3User2.id,
}),
type: SyncEntityType.AlbumAssetV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]);
});
});
@@ -0,0 +1,264 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncRequestType.AlbumToAssetsV1, () => {
it('should detect and sync the first album to asset relation', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should sync album to asset for owned albums', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync the album to asset for shared albums', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should not sync album to asset for an album owned by another user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should backfill album to assets when a user shares an album with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id });
const { asset: album2Asset } = await ctx.newAsset({ ownerId: auth.user.id });
// Backfill album
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: album2Asset.id });
await wait(2);
const { album: album1 } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album1.id,
assetId: album1Asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
// ack initial album to asset sync
await ctx.syncAckAll(auth, response);
// add user to backfill album
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album to asset relation
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album2.id,
assetId: album2Asset.id,
}),
type: SyncEntityType.AlbumToAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumToAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted album to asset relation', async () => {
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await albumRepo.removeAssetIds(album.id, [asset.id]);
await wait(2);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted album to asset relation when an asset is deleted', async () => {
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await assetRepo.remove({ id: asset.id });
await wait(2);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
it('should not sync a deleted album to asset relation when the album is deleted', async () => {
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
await albumRepo.delete(album.id);
await wait(2);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
});
});
@@ -1,17 +1,16 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
@@ -20,198 +19,156 @@ beforeAll(async () => {
describe(SyncRequestType.AlbumUsersV1, () => {
it('should sync an album user with the correct properties', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const { user } = await ctx.newUser();
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
const user = mediumFactory.userInsert();
await userRepo.create(user);
const albumUser = { albumsId: album.id, usersId: user.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.usersId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
});
describe('owner', () => {
it('should detect and sync a new shared user', async () => {
const { auth, testSync, getRepository } = await setup();
const { auth, ctx } = await setup();
const { user: user1 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
await userRepo.create(user1);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.usersId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should detect and sync an updated shared user', async () => {
const { auth, testSync, getRepository, sut } = await setup();
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: user1 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
await userRepo.create(user1);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.VIEWER });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
albumId: albumUser.albumId,
role: AlbumUserRole.VIEWER,
userId: albumUser.usersId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted shared user', async () => {
const { auth, testSync, getRepository, sut } = await setup();
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: user1 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
await userRepo.create(user1);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
userId: albumUser.usersId,
albumId: albumUser.albumId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
});
describe('shared user', () => {
it('should detect and sync a new shared user', async () => {
const { auth, testSync, getRepository } = await setup();
const { auth, ctx } = await setup();
const { user: user1 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user1.id });
const { albumUser } = await ctx.newAlbumUser({
albumId: album.id,
userId: auth.user.id,
role: AlbumUserRole.EDITOR,
});
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
await userRepo.create(user1);
const album = mediumFactory.albumInsert({ ownerId: user1.id });
await albumRepo.create(album, [], []);
const albumUser = { albumsId: album.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR };
await albumUserRepo.create(albumUser);
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumsId,
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.usersId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should detect and sync an updated shared user', async () => {
const { auth, testSync, getRepository, sut } = await setup();
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: owner } = await ctx.newUser();
const { user: user } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: owner.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(2);
const owner = mediumFactory.userInsert();
const user = mediumFactory.userInsert();
await Promise.all([userRepo.create(owner), userRepo.create(user)]);
const album = mediumFactory.albumInsert({ ownerId: owner.id });
await albumRepo.create(
album,
[],
[
{ userId: auth.user.id, role: AlbumUserRole.EDITOR },
{ userId: user.id, role: AlbumUserRole.EDITOR },
],
);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(initialSyncResponse).toHaveLength(2);
const acks = [initialSyncResponse[1].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.VIEWER });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -222,39 +179,29 @@ describe(SyncRequestType.AlbumUsersV1, () => {
type: SyncEntityType.AlbumUserV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted shared user', async () => {
const { auth, testSync, getRepository, sut } = await setup();
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: owner } = await ctx.newUser();
const { user: user } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: owner.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR });
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const owner = mediumFactory.userInsert();
const user = mediumFactory.userInsert();
await Promise.all([userRepo.create(owner), userRepo.create(user)]);
const album = mediumFactory.albumInsert({ ownerId: owner.id });
await albumRepo.create(
album,
[],
[
{ userId: auth.user.id, role: AlbumUserRole.EDITOR },
{ userId: user.id, role: AlbumUserRole.EDITOR },
],
);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(initialSyncResponse).toHaveLength(2);
const acks = [initialSyncResponse[1].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(2);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
await albumUserRepo.delete({ albumsId: album.id, usersId: user.id });
await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -264,35 +211,27 @@ describe(SyncRequestType.AlbumUsersV1, () => {
type: SyncEntityType.AlbumUserDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
it('should backfill album users when a user shares an album with you', async () => {
const { auth, sut, testSync, getRepository } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
const user2 = mediumFactory.userInsert();
await userRepo.create(user1);
await userRepo.create(user2);
const album1 = mediumFactory.albumInsert({ ownerId: user1.id });
const album2 = mediumFactory.albumInsert({ ownerId: user1.id });
await albumRepo.create(album1, [], []);
await albumRepo.create(album2, [], []);
const { auth, ctx } = await setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { album: album1 } = await ctx.newAlbum({ ownerId: user1.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user1.id });
// backfill album user
await albumUserRepo.create({ albumsId: album1.id, usersId: user1.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album1.id, userId: user1.id, role: AlbumUserRole.EDITOR });
await wait(2);
// initial album user
await albumUserRepo.create({ albumsId: album2.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
await wait(2);
// post checkpoint album user
await albumUserRepo.create({ albumsId: album1.id, usersId: user2.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album1.id, userId: user2.id, role: AlbumUserRole.EDITOR });
const response = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
@@ -307,15 +246,13 @@ describe(SyncRequestType.AlbumUsersV1, () => {
]);
// ack initial user
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
// get access to the backfill album user
await albumUserRepo.create({ albumsId: album1.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album user
const backfillResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(backfillResponse).toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -350,10 +287,8 @@ describe(SyncRequestType.AlbumUsersV1, () => {
},
]);
await sut.setAcks(auth, { acks: [backfillResponse[1].ack, backfillResponse.at(-1).ack] });
const finalResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(finalResponse).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
});
});
});
+101 -94
View File
@@ -1,17 +1,17 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
@@ -20,11 +20,12 @@ beforeAll(async () => {
describe(SyncRequestType.AlbumsV1, () => {
it('should sync an album with the correct properties', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const { auth, ctx } = await setup();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -35,14 +36,18 @@ describe(SyncRequestType.AlbumsV1, () => {
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync a new album', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const { auth, ctx } = await setup();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -51,14 +56,19 @@ describe(SyncRequestType.AlbumsV1, () => {
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album delete', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [], []);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -69,7 +79,10 @@ describe(SyncRequestType.AlbumsV1, () => {
]);
await albumRepo.delete(album.id);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
@@ -78,67 +91,60 @@ describe(SyncRequestType.AlbumsV1, () => {
type: SyncEntityType.AlbumDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
describe('shared albums', () => {
it('should detect and sync an album create', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: album.id }),
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album share (share before sync)', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [], []);
await albumUserRepo.create({ usersId: auth.user.id, albumsId: album.id, role: AlbumUserRole.EDITOR });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: album.id }),
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album share (share after sync)', async () => {
const { auth, getRepository, sut, testSync } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { album: userAlbum } = await ctx.newAlbum({ ownerId: auth.user.id });
const { album: user2Album } = await ctx.newAlbum({ ownerId: user2.id });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const userAlbum = mediumFactory.albumInsert({ ownerId: auth.user.id });
const user2Album = mediumFactory.albumInsert({ ownerId: user2.id });
await Promise.all([albumRepo.create(user2Album, [], []), albumRepo.create(userAlbum, [], [])]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
expect(initialSyncResponse).toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: userAlbum.id }),
@@ -146,75 +152,76 @@ describe(SyncRequestType.AlbumsV1, () => {
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
await ctx.newAlbumUser({ userId: auth.user.id, albumId: user2Album.id, role: AlbumUserRole.EDITOR });
await albumUserRepo.create({ usersId: auth.user.id, albumsId: user2Album.id, role: AlbumUserRole.EDITOR });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: user2Album.id }),
type: SyncEntityType.AlbumV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album delete`', async () => {
const { auth, getRepository, testSync, sut } = await setup();
const albumRepo = getRepository('album');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
const { user: user2 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
await albumRepo.delete(album.id);
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: { albumId: album.id },
type: SyncEntityType.AlbumDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
it('should detect and sync an album unshare as an album delete', async () => {
const { auth, getRepository, testSync, sut } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const { auth, ctx } = await setup();
const albumUserRepo = ctx.get(AlbumUserRepository);
const { user: user2 } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toHaveLength(1);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
await albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id });
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: { albumId: album.id },
type: SyncEntityType.AlbumDeleteV1,
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
});
});
});
@@ -1,100 +1,78 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncRequestType.AssetExifsV1, () => {
describe(SyncRequestType.AssetExifsV1, () => {
it('should detect and sync the first asset exif', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
type: SyncEntityType.AssetExifV1,
const response = await ctx.syncStream(auth, [SyncRequestType.AssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
]),
);
type: SyncEntityType.AssetExifV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toEqual([]);
});
it('should only sync asset exif for own user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user2.id });
await sessionRepo.create(session);
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { session } = await ctx.newSession({ userId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0);
});
});
@@ -1,35 +1,32 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { AssetRepository } from 'src/repositories/asset.repository';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncEntityType.AssetV1, () => {
describe(SyncEntityType.AssetV1, () => {
it('should detect and sync the first asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const originalFileName = 'firstAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({
originalFileName,
ownerId: auth.user.id,
checksum: Buffer.from(checksum, 'base64'),
@@ -40,96 +37,70 @@ describe.concurrent(SyncEntityType.AssetV1, () => {
deletedAt: null,
duration: '0:10:00.00000',
});
await assetRepo.create(asset);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
id: asset.id,
originalFileName,
ownerId: asset.ownerId,
thumbhash,
checksum,
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
type: 'AssetV1',
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: asset.id,
originalFileName,
ownerId: asset.ownerId,
thumbhash,
checksum,
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
]),
);
type: 'AssetV1',
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await assetRepo.remove(asset);
const response = await testSync(auth, [SyncRequestType.AssetsV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
type: 'AssetDeleteV1',
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
]),
);
type: 'AssetDeleteV1',
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([]);
});
it('should not sync an asset or asset delete for an unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user2.id });
await sessionRepo.create(session);
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
expect(await ctx.syncStream(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
await assetRepo.remove(asset);
expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
expect(await ctx.syncStream(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
});
});
@@ -0,0 +1,84 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.MemoryToAssetV1, () => {
it('should detect and sync a memory to asset relation', async () => {
const { auth, user, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { memory } = await ctx.newMemory({ ownerId: user.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
memoryId: memory.id,
assetId: asset.id,
},
type: 'MemoryToAssetV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted memory to asset relation', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { memory } = await ctx.newMemory({ ownerId: user.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
await memoryRepo.removeAssetIds(memory.id, [asset.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
memoryId: memory.id,
},
type: 'MemoryToAssetDeleteV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).resolves.toEqual([]);
});
it('should not sync a memory to asset relation or delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { auth: auth2, user: user2 } = await ctx.newSyncAuthUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { memory } = await ctx.newMemory({ ownerId: user2.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
expect(await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(1);
await memoryRepo.removeAssetIds(memory.id, [asset.id]);
expect(await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(1);
});
});
@@ -0,0 +1,115 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.MemoryV1, () => {
it('should detect and sync the first memory with the right properties', async () => {
const { auth, user: user1, ctx } = await setup();
const { memory } = await ctx.newMemory({ ownerId: user1.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: memory.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: memory.deletedAt,
type: memory.type,
data: memory.data,
hideAt: memory.hideAt,
showAt: memory.showAt,
seenAt: memory.seenAt,
memoryAt: expect.any(String),
isSaved: memory.isSaved,
ownerId: memory.ownerId,
},
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted memory', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { memory } = await ctx.newMemory({ ownerId: user.id });
await memoryRepo.delete(memory.id);
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
memoryId: memory.id,
},
type: 'MemoryDeleteV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should sync a memory and then an update to that same memory', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { memory } = await ctx.newMemory({ ownerId: user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: memory.id }),
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, response);
await memoryRepo.update(memory.id, { seenAt: new Date() });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: memory.id }),
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should not sync a memory or a memory delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { user: user2 } = await ctx.newUser();
const { memory } = await ctx.newMemory({ ownerId: user2.id });
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
await memoryRepo.delete(memory.id);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
});
@@ -1,18 +1,16 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
@@ -21,134 +19,89 @@ beforeAll(async () => {
describe(SyncRequestType.PartnerAssetExifsV1, () => {
it('should detect and sync the first partner asset exif', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
type: SyncEntityType.PartnerAssetExifV1,
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
]),
);
type: SyncEntityType.PartnerAssetExifV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
});
it('should not sync partner asset exif for own user', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
});
it('should not sync partner asset exif for unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await Promise.all([userRepo.create(user2), userRepo.create(user3)]);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user3.id });
await sessionRepo.create(session);
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { session } = await ctx.newSession({ userId: user3.id });
const authUser3 = factory.auth({ session, user: user3 });
await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
});
it('should backfill partner asset exif when a partner shared their library with you', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(assetUser3);
await assetRepo.upsertExif({ assetId: assetUser3.id, make: 'Canon' });
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: assetUser3.id, make: 'Canon' });
await wait(2);
await assetRepo.create(assetUser2);
await assetRepo.upsertExif({ assetId: assetUser2.id, make: 'Canon' });
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: assetUser2.id, make: 'Canon' });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
@@ -162,189 +115,133 @@ describe(SyncRequestType.PartnerAssetExifsV1, () => {
]),
);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toHaveLength(2);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
expect(backfillResponse).toHaveLength(2);
expect(backfillResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: {},
type: SyncEntityType.SyncAckV1,
},
]),
);
const backfillAck = backfillResponse[1].ack;
await sut.setAcks(auth, { acks: [backfillAck] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const finalAcks = finalResponse.map(({ ack }) => ack);
expect(finalAcks).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
});
it('should handle partners with users ids lower than a uuidv7', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert({ id: '00d4c0af-7695-4cf2-85e6-415eeaf449cb' });
const user3 = mediumFactory.userInsert({ id: '00e4c0af-7695-4cf2-85e6-415eeaf449cb' });
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(assetUser3);
await assetRepo.upsertExif({ assetId: assetUser3.id, make: 'assetUser3' });
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser({ id: '00d4c0af-7695-4cf2-85e6-415eeaf449cb' });
const { user: user3 } = await ctx.newUser({ id: '00e4c0af-7695-4cf2-85e6-415eeaf449cb' });
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: assetUser3.id, make: 'assetUser3' });
await wait(2);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: assetUser2.id, make: 'assetUser2' });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(assetUser2);
await assetRepo.upsertExif({ assetId: assetUser2.id, make: 'assetUser2' });
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]),
);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]);
await ctx.syncAckAll(auth, response);
// This checks that our ack upsert is correct
const ackUpsertResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(ackUpsertResponse).toEqual([]);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toHaveLength(2);
expect(newResponse).toEqual([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
const syncAckResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(syncAckResponse).toHaveLength(2);
expect(syncAckResponse).toEqual(
expect.arrayContaining([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]),
);
const syncAckResponseAcks = syncAckResponse.map(({ ack }) => ack);
await sut.setAcks(auth, { acks: [syncAckResponseAcks[1]] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(finalResponse).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
});
it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
const asset2User3 = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(assetUser3);
await assetRepo.upsertExif({ assetId: assetUser3.id, make: 'assetUser3' });
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: assetUser3.id, make: 'assetUser3' });
await wait(2);
await assetRepo.create(assetUser2);
await assetRepo.upsertExif({ assetId: assetUser2.id, make: 'assetUser2' });
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: assetUser2.id, make: 'assetUser2' });
await wait(2);
await assetRepo.create(asset2User3);
await assetRepo.upsertExif({ assetId: asset2User3.id, make: 'asset2User3' });
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const { asset: asset2User3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newExif({ assetId: asset2User3.id, make: 'asset2User3' });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]),
);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toHaveLength(3);
expect(newResponse).toEqual([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User3.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]);
expect(backfillResponse).toHaveLength(3);
expect(backfillResponse).toEqual(
expect.arrayContaining([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User3.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
]),
);
const backfillAck = backfillResponse[1].ack;
const partnerAssetAck = backfillResponse[2].ack;
await sut.setAcks(auth, { acks: [backfillAck, partnerAssetAck] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
const finalAcks = finalResponse.map(({ ack }) => ack);
expect(finalAcks).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toEqual([]);
});
});
@@ -1,18 +1,19 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { AssetRepository } from 'src/repositories/asset.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
@@ -21,19 +22,15 @@ beforeAll(async () => {
describe(SyncRequestType.PartnerAssetsV1, () => {
it('should detect and sync the first partner asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const originalFileName = 'firstPartnerAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({
ownerId: user2.id,
originalFileName,
checksum: Buffer.from(checksum, 'base64'),
@@ -44,315 +41,217 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
deletedAt: null,
duration: '0:10:00.00000',
});
await assetRepo.create(asset);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
id: asset.id,
ownerId: asset.ownerId,
originalFileName,
thumbhash,
checksum,
deletedAt: null,
fileCreatedAt: date,
fileModifiedAt: date,
isFavorite: false,
localDateTime: date,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
type: SyncEntityType.PartnerAssetV1,
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: asset.id,
ownerId: asset.ownerId,
originalFileName,
thumbhash,
checksum,
deletedAt: null,
fileCreatedAt: date,
fileModifiedAt: date,
isFavorite: false,
localDateTime: date,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
]),
);
type: SyncEntityType.PartnerAssetV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted partner asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
const assetRepo = getRepository('asset');
await assetRepo.create(asset);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
await assetRepo.remove(asset);
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
type: SyncEntityType.PartnerAssetDeleteV1,
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
]),
);
type: SyncEntityType.PartnerAssetDeleteV1,
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should not sync a deleted partner asset due to a user delete', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id }));
const { auth, ctx } = await setup();
const userRepo = ctx.get(UserRepository);
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
await ctx.newAsset({ ownerId: user2.id });
await userRepo.delete({ id: user2.id }, true);
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const assetRepo = getRepository('asset');
await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id }));
const partnerRepo = getRepository('partner');
const partner = { sharedById: user2.id, sharedWithId: auth.user.id };
await partnerRepo.create(partner);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1);
const { auth, ctx } = await setup();
const partnerRepo = ctx.get(PartnerRepository);
const { user: user2 } = await ctx.newUser();
await ctx.newAsset({ ownerId: user2.id });
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1);
await partnerRepo.remove(partner);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should not sync an asset or asset delete for own user', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await assetRepo.remove(asset);
await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
});
it('should not sync an asset or asset delete for unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user2.id });
await sessionRepo.create(session);
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await assetRepo.remove(asset);
await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
});
it('should backfill partner assets when a partner shared their library with you', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(assetUser3);
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
await assetRepo.create(assetUser2);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]),
);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(newResponse).toHaveLength(2);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
expect(backfillResponse).toHaveLength(2);
expect(backfillResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]),
);
const backfillAck = backfillResponse[1].ack;
await sut.setAcks(auth, { acks: [backfillAck] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const finalAcks = finalResponse.map(({ ack }) => ack);
expect(finalAcks).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
const assetUser3 = mediumFactory.assetInsert({ ownerId: user3.id });
const assetUser2 = mediumFactory.assetInsert({ ownerId: user2.id });
const asset2User3 = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(assetUser3);
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: assetUser3 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
await assetRepo.create(assetUser2);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
await assetRepo.create(asset2User3);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const { asset: asset2User3 } = await ctx.newAsset({ ownerId: user3.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]),
);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]);
await ctx.syncAckAll(auth, response);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(newResponse).toHaveLength(3);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User3.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]);
await partnerRepo.create({ sharedById: user3.id, sharedWithId: auth.user.id });
const backfillResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
expect(backfillResponse).toHaveLength(3);
expect(backfillResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User3.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
]),
);
const backfillAck = backfillResponse[1].ack;
const partnerAssetAck = backfillResponse[2].ack;
await sut.setAcks(auth, { acks: [backfillAck, partnerAssetAck] });
const finalResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
const finalAcks = finalResponse.map(({ ack }) => ack);
expect(finalAcks).toEqual([]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([]);
});
});
@@ -1,75 +1,58 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncEntityType.PartnerV1, () => {
describe(SyncEntityType.PartnerV1, () => {
it('should detect and sync the first partner', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, user: user1, ctx } = await setup();
const user1 = auth.user;
const userRepo = getRepository('user');
const partnerRepo = getRepository('partner');
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
type: 'PartnerV1',
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
]),
);
type: 'PartnerV1',
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted partner', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, user: user1, ctx } = await setup();
const userRepo = getRepository('user');
const user1 = auth.user;
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const partnerRepo = ctx.get(PartnerRepository);
const partnerRepo = getRepository('partner');
const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
await partnerRepo.remove(partner);
const response = await testSync(auth, [SyncRequestType.PartnersV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual(
expect.arrayContaining([
@@ -84,27 +67,18 @@ describe.concurrent(SyncEntityType.PartnerV1, () => {
]),
);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should detect and sync a partner share both to and from another user', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, user: user1, ctx } = await setup();
const userRepo = getRepository('user');
const user1 = auth.user;
const user2 = await userRepo.create(mediumFactory.userInsert());
const partnerRepo = getRepository('partner');
const partner1 = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
const partner2 = await partnerRepo.create({ sharedById: user1.id, sharedWithId: user2.id });
const response = await testSync(auth, [SyncRequestType.PartnersV1]);
const { user: user2 } = await ctx.newUser();
const { partner: partner1 } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
const { partner: partner2 } = await ctx.newPartner({ sharedById: user1.id, sharedWithId: user2.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toHaveLength(2);
expect(response).toEqual(
expect.arrayContaining([
@@ -129,93 +103,80 @@ describe.concurrent(SyncEntityType.PartnerV1, () => {
]),
);
await sut.setAcks(auth, { acks: [response[1].ack] });
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should sync a partner and then an update to that same partner', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const { auth, user: user1, ctx } = await setup();
const userRepo = getRepository('user');
const user1 = auth.user;
const user2 = await userRepo.create(mediumFactory.userInsert());
const partnerRepo = ctx.get(PartnerRepository);
const partnerRepo = getRepository('partner');
const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
type: 'PartnerV1',
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
]),
);
type: 'PartnerV1',
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
const updated = await partnerRepo.update(
{ sharedById: partner.sharedById, sharedWithId: partner.sharedWithId },
{ inTimeline: true },
);
const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
expect(updatedSyncResponse).toHaveLength(1);
expect(updatedSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
inTimeline: updated.inTimeline,
sharedById: updated.sharedById,
sharedWithId: updated.sharedWithId,
},
type: 'PartnerV1',
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: updated.inTimeline,
sharedById: updated.sharedById,
sharedWithId: updated.sharedWithId,
},
]),
);
type: 'PartnerV1',
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should not sync a partner or partner delete for an unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const userRepo = getRepository('user');
const user2 = await userRepo.create(mediumFactory.userInsert());
const user3 = await userRepo.create(mediumFactory.userInsert());
const partnerRepo = ctx.get(PartnerRepository);
const partnerRepo = getRepository('partner');
const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user3.id });
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user3.id });
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
await partnerRepo.remove(partner);
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
it('should not sync a partner delete after a user is deleted', async () => {
const { auth, getRepository, testSync } = await setup();
const { auth, ctx } = await setup();
const userRepo = getRepository('user');
const user2 = await userRepo.create(mediumFactory.userInsert());
const userRepo = ctx.get(UserRepository);
const partnerRepo = getRepository('partner');
await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
await userRepo.delete({ id: user2.id }, true);
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
await expect(ctx.syncStream(auth, [SyncRequestType.PartnersV1])).resolves.toEqual([]);
});
});
@@ -9,4 +9,11 @@ describe('types', () => {
expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length);
});
it('should ensure album follows albums assets', () => {
const albumIndex = SYNC_TYPES_ORDER.indexOf(SyncRequestType.AlbumsV1);
const albumAssetsIndex = SYNC_TYPES_ORDER.indexOf(SyncRequestType.AlbumAssetsV1);
expect(albumIndex).toBeGreaterThan(albumAssetsIndex);
});
});
+71 -90
View File
@@ -1,36 +1,35 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { UserRepository } from 'src/repositories/user.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncEntityType.UserV1, () => {
describe(SyncEntityType.UserV1, () => {
it('should detect and sync the first user', async () => {
const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
const { auth, ctx } = await setup(await getKyselyDB());
const userRepo = getRepository('user');
const userRepo = ctx.get(UserRepository);
const user = await userRepo.get(auth.user.id, { withDeleted: false });
if (!user) {
expect.fail('First user should exist');
}
const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual([
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
@@ -43,21 +42,17 @@ describe.concurrent(SyncEntityType.UserV1, () => {
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.UsersV1])).resolves.toEqual([]);
});
it('should detect and sync a soft deleted user', async () => {
const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
const { auth, ctx } = await setup(await getKyselyDB());
const deletedAt = new Date().toISOString();
const deletedUser = mediumFactory.userInsert({ deletedAt });
const deleted = await getRepository('user').create(deletedUser);
const { user: deleted } = await ctx.newUser({ deletedAt });
const response = await testSync(auth, [SyncRequestType.UsersV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toHaveLength(2);
expect(response).toEqual(
@@ -85,95 +80,81 @@ describe.concurrent(SyncEntityType.UserV1, () => {
]),
);
const acks = [response[1].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.UsersV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted user', async () => {
const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
const { auth, ctx } = await setup(await getKyselyDB());
const userRepo = getRepository('user');
const user = mediumFactory.userInsert();
await userRepo.create(user);
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser();
await userRepo.delete({ id: user.id }, true);
const response = await testSync(auth, [SyncRequestType.UsersV1]);
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toHaveLength(2);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
userId: user.id,
},
type: 'UserDeleteV1',
expect(response).toEqual([
{
ack: expect.any(String),
data: {
userId: user.id,
},
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
type: 'UserV1',
type: 'UserDeleteV1',
},
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
]),
);
type: 'UserV1',
},
]);
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(ackSyncResponse).toHaveLength(0);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.UsersV1])).resolves.toEqual([]);
});
it('should sync a user and then an update to that same user', async () => {
const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
const { auth, ctx } = await setup(await getKyselyDB());
const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
const userRepo = ctx.get(UserRepository);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
type: 'UserV1',
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
]),
);
type: 'UserV1',
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await ctx.syncAckAll(auth, response);
const userRepo = getRepository('user');
const updated = await userRepo.update(auth.user.id, { name: 'new name' });
const updatedSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
expect(updatedSyncResponse).toHaveLength(1);
expect(updatedSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: updated.name,
},
type: 'UserV1',
const newResponse = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: updated.name,
},
]),
);
type: 'UserV1',
},
]);
});
});
@@ -1,38 +0,0 @@
import { PersonRepository } from 'src/repositories/person.repository';
import { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepository>> => {
return {
reassignFaces: vitest.fn(),
unassignFaces: vitest.fn(),
delete: vitest.fn(),
deleteFaces: vitest.fn(),
getAllFaces: vitest.fn(),
getAll: vitest.fn(),
getAllForUser: vitest.fn(),
getAllWithoutFaces: vitest.fn(),
getFaces: vitest.fn(),
getFaceById: vitest.fn(),
getFaceForFacialRecognitionJob: vitest.fn(),
getDataForThumbnailGenerationJob: vitest.fn(),
reassignFace: vitest.fn(),
getById: vitest.fn(),
getByName: vitest.fn(),
getDistinctNames: vitest.fn(),
getStatistics: vitest.fn(),
getNumberOfPeople: vitest.fn(),
create: vitest.fn(),
createAll: vitest.fn(),
refreshFaces: vitest.fn(),
update: vitest.fn(),
updateAll: vitest.fn(),
getFacesByIds: vitest.fn(),
getRandomFace: vitest.fn(),
getLatestFaceDate: vitest.fn(),
createAssetFace: vitest.fn(),
deleteAssetFace: vitest.fn(),
softDeleteAssetFaces: vitest.fn(),
vacuum: vitest.fn(),
};
};
+5 -2
View File
@@ -47,6 +47,7 @@ import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
@@ -67,7 +68,6 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
@@ -218,6 +218,7 @@ export type ServiceOverrides = {
stack: StackRepository;
storage: StorageRepository;
sync: SyncRepository;
syncCheckpoint: SyncCheckpointRepository;
systemMetadata: SystemMetadataRepository;
tag: TagRepository;
telemetry: TelemetryRepository;
@@ -278,7 +279,7 @@ export const newTestService = <T extends BaseService>(
notification: automock(NotificationRepository),
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: newPersonRepositoryMock(),
person: automock(PersonRepository, { strict: false }),
process: automock(ProcessRepository),
search: automock(SearchRepository, { strict: false }),
// eslint-disable-next-line no-sparse-arrays
@@ -288,6 +289,7 @@ export const newTestService = <T extends BaseService>(
stack: automock(StackRepository),
storage: newStorageRepositoryMock(),
sync: automock(SyncRepository),
syncCheckpoint: automock(SyncCheckpointRepository),
systemMetadata: newSystemMetadataRepositoryMock(),
// systemMetadata: automock(SystemMetadataRepository, { strict: false }),
// eslint-disable-next-line no-sparse-arrays
@@ -337,6 +339,7 @@ export const newTestService = <T extends BaseService>(
overrides.stack || (mocks.stack as As<StackRepository>),
overrides.storage || (mocks.storage as As<StorageRepository>),
overrides.sync || (mocks.sync as As<SyncRepository>),
overrides.syncCheckpoint || (mocks.syncCheckpoint as As<SyncCheckpointRepository>),
overrides.systemMetadata || (mocks.systemMetadata as As<SystemMetadataRepository>),
overrides.tag || (mocks.tag as As<TagRepository>),
overrides.telemetry || (mocks.telemetry as unknown as TelemetryRepository),