Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,373 @@
import { Kysely } from 'kysely';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema';
import { updateLockedColumns } from 'src/utils/database';
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 };
};
const updateSyncAck = {
ack: expect.stringContaining(SyncEntityType.AlbumAssetExifUpdateV1),
data: {},
type: SyncEntityType.SyncAckV1,
};
const backfillSyncAck = {
ack: expect.stringContaining(SyncEntityType.AlbumAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
};
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).toEqual([
updateSyncAck,
{
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.AlbumAssetExifCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetExifsV1]);
});
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.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetExifV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumAssetExifCreateV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
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.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetExifV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetExifsV1]);
});
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 { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset1User2.id, make: 'asset1User2' });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset1User2.id });
await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: asset2User2.id, make: 'asset2User2' });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset2User2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id });
await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset3User2.id });
await ctx.newExif({ assetId: asset3User2.id, make: 'asset3User2' });
await wait(2);
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(response).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetExifCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack initial album asset exif sync
await ctx.syncAckAll(auth, response);
// create a second album
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: asset1User2.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
backfillSyncAck,
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset3User2.id,
}),
type: SyncEntityType.AlbumAssetExifCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetExifsV1]);
});
it('should sync old asset exif when a user adds them to an album they share you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: firstAsset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'firstAsset' });
await ctx.newExif({ assetId: firstAsset.id, make: 'firstAsset' });
const { asset: secondAsset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'secondAsset' });
await ctx.newExif({ assetId: secondAsset.id, make: 'secondAsset' });
const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'album1Asset' });
await ctx.newExif({ assetId: album1Asset.id, make: 'album1Asset' });
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: firstAsset.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(firstAlbumResponse).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: album1Asset.id,
}),
type: SyncEntityType.AlbumAssetExifCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, firstAlbumResponse);
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: firstAsset.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
backfillSyncAck,
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack initial album asset sync
await ctx.syncAckAll(auth, response);
await ctx.newAlbumAsset({ albumId: album2.id, assetId: secondAsset.id });
await wait(2);
// should backfill the new asset even though it's older than the first asset
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(newResponse).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: secondAsset.id,
}),
type: SyncEntityType.AlbumAssetExifCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetExifsV1]);
});
it('should sync asset exif updates for an album shared with you', 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: 'asset' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await wait(2);
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).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset.id,
}),
type: SyncEntityType.AlbumAssetExifCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif(
updateLockedColumns({
assetId: asset.id,
city: 'New City',
}),
{ lockedPropertiesBehavior: 'append' },
);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset.id,
city: 'New City',
}),
type: SyncEntityType.AlbumAssetExifUpdateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
it('should sync delayed asset exif creates for an album shared with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: assetWithExif } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: assetWithExif.id, make: 'assetWithExif' });
const { asset: assetDelayedExif } = await ctx.newAsset({ ownerId: user2.id });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
const { asset: newerAsset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: newerAsset.id, make: 'newerAsset' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: assetWithExif.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album.id, assetId: assetDelayedExif.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album.id, assetId: newerAsset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(response).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetWithExif.id,
}),
type: SyncEntityType.AlbumAssetExifCreateV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: newerAsset.id,
}),
type: SyncEntityType.AlbumAssetExifCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif(
updateLockedColumns({
assetId: assetDelayedExif.id,
city: 'Delayed Exif',
}),
{ lockedPropertiesBehavior: 'append' },
);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetDelayedExif.id,
city: 'Delayed Exif',
}),
type: SyncEntityType.AlbumAssetExifUpdateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});

View file

@ -0,0 +1,312 @@
import { Kysely } from 'kysely';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema';
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 };
};
const updateSyncAck = {
ack: expect.stringContaining(SyncEntityType.AlbumAssetUpdateV1),
data: {},
type: SyncEntityType.SyncAckV1,
};
const backfillSyncAck = {
ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
};
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',
livePhotoVideoId: null,
stackId: null,
libraryId: null,
width: 1920,
height: 1080,
});
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).toEqual([
updateSyncAck,
{
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,
livePhotoVideoId: asset.livePhotoVideoId,
stackId: asset.stackId,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
isEdited: asset.isEdited,
},
type: SyncEntityType.AlbumAssetCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]);
});
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.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumAssetCreateV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
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.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]);
});
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 { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset1User2.id });
await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset2User2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id });
await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset3User2.id });
await wait(2);
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack initial album asset sync
await ctx.syncAckAll(auth, response);
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: asset1User2.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
backfillSyncAck,
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset3User2.id,
}),
type: SyncEntityType.AlbumAssetCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]);
});
it('should sync old assets when a user adds them to an album they share you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset: firstAsset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'firstAsset' });
const { asset: secondAsset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'secondAsset' });
const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'album1Asset' });
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: firstAsset.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(firstAlbumResponse).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
id: album1Asset.id,
}),
type: SyncEntityType.AlbumAssetCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, firstAlbumResponse);
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: firstAsset.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
backfillSyncAck,
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack initial album asset sync
await ctx.syncAckAll(auth, response);
await ctx.newAlbumAsset({ albumId: album2.id, assetId: secondAsset.id });
await wait(2);
// should backfill the new asset even though it's older than the first asset
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(newResponse).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
id: secondAsset.id,
}),
type: SyncEntityType.AlbumAssetCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]);
});
it('should sync asset updates for an album shared with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: false });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await wait(2);
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).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset.id,
}),
type: SyncEntityType.AlbumAssetCreateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.update({
id: asset.id,
isFavorite: true,
});
const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(updateResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset.id,
isFavorite: true,
}),
type: SyncEntityType.AlbumAssetUpdateV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});

View file

@ -0,0 +1,265 @@
import { Kysely } from 'kysely';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema';
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).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
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 ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: {
albumId: album1.id,
assetId: album1Asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// 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,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
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).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await assetRepo.remove({ id: asset.id });
await wait(2);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await albumRepo.delete(album.id);
await wait(2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
});

View file

@ -0,0 +1,306 @@
import { Kysely } from 'kysely';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { DB } from 'src/schema';
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.AlbumUsersV1, () => {
it('should sync an album user with the correct properties', async () => {
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 });
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
describe('owner', () => {
it('should detect and sync a new shared user', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
});
it('should detect and sync an updated shared user', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
await albumUserRepo.update({ albumId: album.id, userId: user1.id }, { role: AlbumUserRole.Viewer });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumId,
role: AlbumUserRole.Viewer,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
});
it('should detect and sync a deleted shared user', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
await albumUserRepo.delete({ albumId: album.id, userId: user1.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumId,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
});
});
describe('shared user', () => {
it('should detect and sync a new shared user', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: albumUser.albumId,
role: albumUser.role,
userId: albumUser.userId,
}),
type: SyncEntityType.AlbumUserV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
});
it('should detect and sync an updated shared user', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
await albumUserRepo.update({ albumId: album.id, userId: user.id }, { role: AlbumUserRole.Viewer });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
role: AlbumUserRole.Viewer,
userId: user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
});
it('should detect and sync a deleted shared user', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
await albumUserRepo.delete({ albumId: album.id, userId: user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
userId: user.id,
}),
type: SyncEntityType.AlbumUserDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
});
it('should backfill album users when a user shares an album with you', async () => {
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 ctx.newAlbumUser({ albumId: album1.id, userId: user1.id, role: AlbumUserRole.Editor });
await wait(2);
// initial album user
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor });
await wait(2);
// post checkpoint album user
await ctx.newAlbumUser({ albumId: album1.id, userId: user2.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album2.id,
role: AlbumUserRole.Editor,
userId: auth.user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack initial user
await ctx.syncAckAll(auth, response);
// get access to the backfill album user
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
// should backfill the album user
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.Editor,
userId: user1.id,
}),
type: SyncEntityType.AlbumUserBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumUserBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.Editor,
userId: user2.id,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.Editor,
userId: auth.user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
});
});
});

View file

@ -0,0 +1,235 @@
import { Kysely } from 'kysely';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { DB } from 'src/schema';
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(SyncRequestType.AlbumsV1, () => {
it('should sync an album with the correct properties', async () => {
const { auth, ctx } = await setup();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: album.id,
name: album.albumName,
ownerId: album.ownerId,
}),
type: SyncEntityType.AlbumV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
});
it('should detect and sync a new album', async () => {
const { auth, ctx } = await setup();
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: album.id,
}),
type: SyncEntityType.AlbumV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
});
it('should detect and sync an album delete', async () => {
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).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: album.id,
}),
type: SyncEntityType.AlbumV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await albumRepo.delete(album.id);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
},
type: SyncEntityType.AlbumDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
});
describe('shared albums', () => {
it('should detect and sync an album create', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: album.id }),
type: SyncEntityType.AlbumV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
});
it('should detect and sync an album share (share before sync)', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: album.id }),
type: SyncEntityType.AlbumV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
});
it('should detect and sync an album share (share after sync)', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: userAlbum.id }),
type: SyncEntityType.AlbumV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.newAlbumUser({ userId: auth.user.id, albumId: user2Album.id, role: AlbumUserRole.Editor });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: user2Album.id }),
type: SyncEntityType.AlbumV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
});
it('should detect and sync an album delete`', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
await albumRepo.delete(album.id);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: { albumId: album.id },
type: SyncEntityType.AlbumDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
});
it('should detect and sync an album unshare as an album delete', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
await albumUserRepo.delete({ albumId: album.id, userId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: { albumId: album.id },
type: SyncEntityType.AlbumDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
});
});
});

View file

@ -0,0 +1,81 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { DB } from 'src/schema';
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 ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncRequestType.AssetExifsV1, () => {
it('should detect and sync the first asset exif', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const response = await ctx.syncStream(auth, [SyncRequestType.AssetExifsV1]);
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,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetExifsV1]);
});
it('should only sync asset exif for own user', async () => {
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(ctx.syncStream(auth2, [SyncRequestType.AssetExifsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetExifV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetExifsV1]);
});
});

View file

@ -0,0 +1,99 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { PersonRepository } from 'src/repositories/person.repository';
import { DB } from 'src/schema';
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 ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.AssetFaceV1, () => {
it('should detect and sync the first asset face', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetFace.id,
assetId: asset.id,
personId: person.id,
imageWidth: assetFace.imageWidth,
imageHeight: assetFace.imageHeight,
boundingBoxX1: assetFace.boundingBoxX1,
boundingBoxY1: assetFace.boundingBoxY1,
boundingBoxX2: assetFace.boundingBoxX2,
boundingBoxY2: assetFace.boundingBoxY2,
sourceType: assetFace.sourceType,
}),
type: 'AssetFaceV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]);
});
it('should detect and sync a deleted asset face', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id });
await personRepo.deleteAssetFace(assetFace.id);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetFaceId: assetFace.id,
},
type: 'AssetFaceDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]);
});
it('should not sync an asset face or asset face delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id });
const auth2 = factory.auth({ session, user: user2 });
expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.AssetFaceV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]);
await personRepo.deleteAssetFace(assetFace.id);
expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.AssetFaceDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]);
});
});

View file

@ -0,0 +1,128 @@
import { Kysely } from 'kysely';
import { AssetMetadataKey, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema';
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.AssetMetadataV1, () => {
it('should detect and sync new asset metadata', async () => {
const { auth, user, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: AssetMetadataKey.MobileApp,
assetId: asset.id,
value: { iCloudId: 'abc123' },
},
type: 'AssetMetadataV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetMetadataV1]);
});
it('should update asset metadata', async () => {
const { auth, user, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: AssetMetadataKey.MobileApp,
assetId: asset.id,
value: { iCloudId: 'abc123' },
},
type: 'AssetMetadataV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc456' } }]);
const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]);
expect(updatedResponse).toEqual([
{
ack: expect.any(String),
data: {
key: AssetMetadataKey.MobileApp,
assetId: asset.id,
value: { iCloudId: 'abc456' },
},
type: 'AssetMetadataV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, updatedResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetMetadataV1]);
});
});
describe(SyncEntityType.AssetMetadataDeleteV1, () => {
it('should delete and sync asset metadata', async () => {
const { auth, user, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: AssetMetadataKey.MobileApp,
assetId: asset.id,
value: { iCloudId: 'abc123' },
},
type: 'AssetMetadataV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await assetRepo.deleteMetadataByKey(asset.id, AssetMetadataKey.MobileApp);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
key: AssetMetadataKey.MobileApp,
},
type: 'AssetMetadataDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});

View file

@ -0,0 +1,121 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema';
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 ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.AssetV1, () => {
it('should detect and sync the first asset', async () => {
const originalFileName = 'firstAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({
originalFileName,
ownerId: auth.user.id,
checksum: Buffer.from(checksum, 'base64'),
thumbhash: Buffer.from(thumbhash, 'base64'),
fileCreatedAt: date,
fileModifiedAt: date,
localDateTime: date,
deletedAt: null,
duration: '0:10:00.00000',
libraryId: null,
width: 1920,
height: 1080,
});
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
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,
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
isEdited: asset.isEdited,
},
type: 'AssetV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]);
});
it('should detect and sync a deleted asset', async () => {
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 ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
type: 'AssetDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]);
});
it('should not sync an asset or asset delete for an unrelated user', async () => {
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 ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]);
await assetRepo.remove(asset);
expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.AssetDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]);
});
});

View file

@ -0,0 +1,106 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
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.AuthUserV1, () => {
it('should detect and sync the first user', async () => {
const { auth, user, ctx } = await setup(await getKyselyDB());
const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: user.id,
isAdmin: user.isAdmin,
deletedAt: user.deletedAt,
name: user.name,
avatarColor: user.avatarColor,
email: user.email,
pinCode: user.pinCode,
hasProfileImage: false,
profileChangedAt: (user.profileChangedAt as Date).toISOString(),
oauthId: user.oauthId,
quotaSizeInBytes: user.quotaSizeInBytes,
quotaUsageInBytes: user.quotaUsageInBytes,
storageLabel: user.storageLabel,
},
type: 'AuthUserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AuthUsersV1]);
});
it('should sync a change and then another change to that same user', async () => {
const { auth, user, ctx } = await setup(await getKyselyDB());
const userRepo = ctx.get(UserRepository);
const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: user.id,
isAdmin: false,
}),
type: 'AuthUserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await userRepo.update(user.id, { isAdmin: true });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: user.id,
isAdmin: true,
}),
type: 'AuthUserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
it('should only sync the auth user', async () => {
const { auth, user, ctx } = await setup(await getKyselyDB());
await ctx.newUser();
const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: user.id,
isAdmin: false,
}),
type: 'AuthUserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});

View file

@ -0,0 +1,60 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
import { DB } from 'src/schema';
import { toAck } from 'src/utils/sync';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
import { v7 } from 'uuid';
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.SyncCompleteV1, () => {
it('should work', async () => {
const { auth, ctx } = await setup();
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]);
});
it('should detect an old checkpoint and send back a reset', async () => {
const { auth, session, ctx } = await setup();
const updateId = v7({ msecs: DateTime.now().minus({ days: 60 }).toMillis() });
await ctx.get(SyncCheckpointRepository).upsertAll([
{
type: SyncEntityType.SyncCompleteV1,
sessionId: session.id,
ack: toAck({ type: SyncEntityType.SyncCompleteV1, updateId }),
},
]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
expect(response).toEqual([{ type: SyncEntityType.SyncResetV1, data: {}, ack: 'SyncResetV1|reset' }]);
});
it('should not send back a reset if the checkpoint is recent', async () => {
const { auth, session, ctx } = await setup();
const updateId = v7({ msecs: DateTime.now().minus({ days: 7 }).toMillis() });
await ctx.get(SyncCheckpointRepository).upsertAll([
{
type: SyncEntityType.SyncCompleteV1,
sessionId: session.id,
ack: toAck({ type: SyncEntityType.SyncCompleteV1, updateId }),
},
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]);
});
});

View file

@ -0,0 +1,91 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { DB } from 'src/schema';
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).toEqual([
{
ack: expect.any(String),
data: {
memoryId: memory.id,
assetId: asset.id,
},
type: 'MemoryToAssetV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoryToAssetsV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
memoryId: memory.id,
},
type: 'MemoryToAssetDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoryToAssetsV1]);
});
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(auth2, [SyncRequestType.MemoryToAssetsV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.MemoryToAssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoryToAssetsV1]);
await memoryRepo.removeAssetIds(memory.id, [asset.id]);
expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.MemoryToAssetDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoryToAssetsV1]);
});
});

View file

@ -0,0 +1,115 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { DB } from 'src/schema';
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).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',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoriesV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: {
memoryId: memory.id,
},
type: 'MemoryDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoriesV1]);
});
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).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: memory.id }),
type: 'MemoryV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await memoryRepo.update(memory.id, { seenAt: new Date() });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: memory.id }),
type: 'MemoryV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoriesV1]);
});
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 ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoriesV1]);
await memoryRepo.delete(memory.id);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.MemoriesV1]);
});
});

View file

@ -0,0 +1,253 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { DB } from 'src/schema';
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.PartnerAssetExifsV1, () => {
it('should detect and sync the first partner asset exif', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
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,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetExifsV1]);
});
it('should not sync partner asset exif for own user', async () => {
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' });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetExifV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetExifsV1]);
});
it('should not sync partner asset exif for unrelated user', async () => {
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(ctx.syncStream(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetExifV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetExifsV1]);
});
it('should backfill partner asset exif when a partner shared their library with you', async () => {
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);
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).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]),
);
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
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.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetExifsV1]);
});
it('should handle partners with users ids lower than a uuidv7', async () => {
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 response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
// This checks that our ack upsert is correct
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetExifsV1]);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toEqual([
{
ack: expect.stringMatching(new RegExp(String.raw`${SyncEntityType.PartnerAssetExifBackfillV1}\|.+?\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
type: SyncEntityType.PartnerAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetExifsV1]);
});
it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => {
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);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newExif({ assetId: assetUser2.id, make: 'assetUser2' });
await wait(2);
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).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetExifV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toEqual([
{
ack: expect.stringMatching(new RegExp(String.raw`${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.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetExifsV1]);
});
});

View file

@ -0,0 +1,279 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
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.PartnerAssetsV1, () => {
it('should detect and sync the first partner asset', async () => {
const { auth, ctx } = await setup();
const originalFileName = 'firstPartnerAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({
ownerId: user2.id,
originalFileName,
checksum: Buffer.from(checksum, 'base64'),
thumbhash: Buffer.from(thumbhash, 'base64'),
fileCreatedAt: date,
fileModifiedAt: date,
localDateTime: date,
deletedAt: null,
duration: '0:10:00.00000',
libraryId: null,
});
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
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,
isEdited: asset.isEdited,
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: null,
height: null,
},
type: SyncEntityType.PartnerAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
});
it('should detect and sync a deleted partner asset', async () => {
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
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 ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
},
type: SyncEntityType.PartnerAssetDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
});
it('should not sync a deleted partner asset due to a user delete', async () => {
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);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
});
it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => {
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.toEqual([
expect.objectContaining({ type: SyncEntityType.PartnerAssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await partnerRepo.remove(partner);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
});
it('should not sync an asset or asset delete for own user', async () => {
const { auth, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
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 });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
await assetRepo.remove(asset);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
});
it('should not sync an asset or asset delete for unrelated user', async () => {
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 });
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
await assetRepo.remove(asset);
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
});
it('should backfill partner assets when a partner shared their library with you', async () => {
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 wait(2);
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).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
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.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
});
it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => {
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 wait(2);
const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
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).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: assetUser2.id,
}),
type: SyncEntityType.PartnerAssetV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]);
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,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]);
});
});

View file

@ -0,0 +1,247 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
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.PartnerStacksV1, () => {
it('should detect and sync the first partner stack', async () => {
const { auth, user, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { stack } = await ctx.newStack({ ownerId: user2.id }, [asset.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerStacksV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: stack.id,
ownerId: stack.ownerId,
createdAt: (stack.createdAt as Date).toISOString(),
updatedAt: (stack.updatedAt as Date).toISOString(),
primaryAssetId: stack.primaryAssetId,
},
type: SyncEntityType.PartnerStackV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
});
it('should detect and sync a deleted partner stack', async () => {
const { auth, user, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { stack } = await ctx.newStack({ ownerId: user2.id }, [asset.id]);
await stackRepo.delete(stack.id);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerStacksV1]);
expect(response).toEqual([
{
ack: expect.stringContaining('PartnerStackDeleteV1'),
data: {
stackId: stack.id,
},
type: SyncEntityType.PartnerStackDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
});
it('should not sync a deleted partner stack due to a user delete', async () => {
const { auth, user, ctx } = await setup();
const userRepo = ctx.get(UserRepository);
const { user: user2 } = await ctx.newUser();
await ctx.newPartner({ sharedById: user2.id, sharedWithId: user.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newStack({ ownerId: user2.id }, [asset.id]);
await userRepo.delete({ id: user2.id }, true);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
});
it('should not sync a deleted partner stack due to a partner delete (unshare)', async () => {
const { auth, user, ctx } = await setup();
const partnerRepo = ctx.get(PartnerRepository);
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newStack({ ownerId: user2.id }, [asset.id]);
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user.id });
await expect(ctx.syncStream(auth, [SyncRequestType.PartnerStacksV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.PartnerStackV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await partnerRepo.remove(partner);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
});
it('should not sync a stack or stack delete for own user', async () => {
const { auth, user, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { stack } = await ctx.newStack({ ownerId: user.id }, [asset.id]);
await ctx.newPartner({ sharedById: user2.id, sharedWithId: user.id });
await expect(ctx.syncStream(auth, [SyncRequestType.StacksV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.StackV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
await stackRepo.delete(stack.id);
await expect(ctx.syncStream(auth, [SyncRequestType.StacksV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.StackDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
});
it('should not sync a stack or stack delete for unrelated user', async () => {
const { auth, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { stack } = await ctx.newStack({ ownerId: user2.id }, [asset.id]);
const auth2 = factory.auth({ session, user: user2 });
await expect(ctx.syncStream(auth2, [SyncRequestType.StacksV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.StackV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
await stackRepo.delete(stack.id);
await expect(ctx.syncStream(auth2, [SyncRequestType.StacksV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.StackDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
});
it('should backfill partner stacks when a partner shared their library with you', async () => {
const { auth, user, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: asset3 } = await ctx.newAsset({ ownerId: user3.id });
const { stack: stack3 } = await ctx.newStack({ ownerId: user3.id }, [asset3.id]);
await wait(2);
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
const { stack: stack2 } = await ctx.newStack({ ownerId: user2.id }, [asset2.id]);
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerStacksV1]);
expect(response).toEqual([
{
ack: expect.stringContaining('PartnerStackV1'),
data: expect.objectContaining({
id: stack2.id,
}),
type: SyncEntityType.PartnerStackV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerStacksV1]);
expect(newResponse).toEqual([
{
ack: expect.stringContaining(SyncEntityType.PartnerStackBackfillV1),
data: expect.objectContaining({
id: stack3.id,
}),
type: SyncEntityType.PartnerStackBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerStackBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
});
it('should only backfill partner stacks created prior to the current partner stack checkpoint', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { asset: asset3 } = await ctx.newAsset({ ownerId: user3.id });
const { stack: stack3 } = await ctx.newStack({ ownerId: user3.id }, [asset3.id]);
await wait(2);
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
const { stack: stack2 } = await ctx.newStack({ ownerId: user2.id }, [asset2.id]);
await wait(2);
const { asset: asset4 } = await ctx.newAsset({ ownerId: user3.id });
const { stack: stack4 } = await ctx.newStack({ ownerId: user3.id }, [asset4.id]);
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerStacksV1]);
expect(response).toEqual([
{
ack: expect.stringContaining(SyncEntityType.PartnerStackV1),
data: expect.objectContaining({
id: stack2.id,
}),
type: SyncEntityType.PartnerStackV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerStacksV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: stack3.id,
}),
type: SyncEntityType.PartnerStackBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.PartnerStackBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: stack4.id,
}),
type: SyncEntityType.PartnerStackV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerStacksV1]);
});
});

View file

@ -0,0 +1,178 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
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.PartnerV1, () => {
it('should detect and sync the first partner', async () => {
const { auth, user: user1, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
type: 'PartnerV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnersV1]);
});
it('should detect and sync a deleted partner', async () => {
const { auth, user: user1, ctx } = await setup();
const partnerRepo = ctx.get(PartnerRepository);
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
await partnerRepo.remove(partner);
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
type: 'PartnerDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnersV1]);
});
it('should detect and sync a partner share both to and from another user', async () => {
const { auth, user: user1, ctx } = await setup();
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).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: partner1.inTimeline,
sharedById: partner1.sharedById,
sharedWithId: partner1.sharedWithId,
},
type: 'PartnerV1',
},
{
ack: expect.any(String),
data: {
inTimeline: partner2.inTimeline,
sharedById: partner2.sharedById,
sharedWithId: partner2.sharedWithId,
},
type: 'PartnerV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnersV1]);
});
it('should sync a partner and then an update to that same partner', async () => {
const { auth, user: user1, ctx } = await setup();
const partnerRepo = ctx.get(PartnerRepository);
const { user: user2 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user1.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: partner.inTimeline,
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
},
type: 'PartnerV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
const updated = await partnerRepo.update(
{ sharedById: partner.sharedById, sharedWithId: partner.sharedWithId },
{ inTimeline: true },
);
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
inTimeline: updated.inTimeline,
sharedById: updated.sharedById,
sharedWithId: updated.sharedWithId,
},
type: 'PartnerV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnersV1]);
});
it('should not sync a partner or partner delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const partnerRepo = ctx.get(PartnerRepository);
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: user3.id });
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnersV1]);
await partnerRepo.remove(partner);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnersV1]);
});
it('should not sync a partner delete after a user is deleted', async () => {
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 userRepo.delete({ id: user2.id }, true);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnersV1]);
});
});

View file

@ -0,0 +1,93 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { PersonRepository } from 'src/repositories/person.repository';
import { DB } from 'src/schema';
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 ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.PersonV1, () => {
it('should detect and sync the first person', async () => {
const { auth, ctx } = await setup();
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: person.id,
name: person.name,
isHidden: person.isHidden,
birthDate: person.birthDate,
faceAssetId: person.faceAssetId,
isFavorite: person.isFavorite,
ownerId: auth.user.id,
color: person.color,
}),
type: 'PersonV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]);
});
it('should detect and sync a deleted person', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
await personRepo.delete([person.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
personId: person.id,
},
type: 'PersonDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]);
});
it('should not sync a person or person delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { person } = await ctx.newPerson({ ownerId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.PersonV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]);
await personRepo.delete([person.id]);
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.PersonDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]);
});
});

View file

@ -0,0 +1,94 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { SessionRepository } from 'src/repositories/session.repository';
import { DB } from 'src/schema';
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.SyncResetV1, () => {
it('should work', async () => {
const { auth, ctx } = await setup();
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]);
});
it('should detect a pending sync reset', async () => {
const { auth, ctx } = await setup();
await ctx.get(SessionRepository).update(auth.session!.id, {
isPendingSyncReset: true,
});
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
expect(response).toEqual([{ type: SyncEntityType.SyncResetV1, data: {}, ack: 'SyncResetV1|reset' }]);
});
it('should not send other dtos when a reset is pending', async () => {
const { auth, user, ctx } = await setup();
await ctx.newAsset({ ownerId: user.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.get(SessionRepository).update(auth.session!.id, {
isPendingSyncReset: true,
});
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([
{ type: SyncEntityType.SyncResetV1, data: {}, ack: 'SyncResetV1|reset' },
]);
});
it('should allow resetting a pending reset when requesting changes ', async () => {
const { auth, user, ctx } = await setup();
await ctx.newAsset({ ownerId: user.id });
await ctx.get(SessionRepository).update(auth.session!.id, {
isPendingSyncReset: true,
});
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1], true)).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
it('should reset the sync progress', async () => {
const { auth, user, ctx } = await setup();
await ctx.newAsset({ ownerId: user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
await ctx.syncAckAll(auth, response);
await ctx.get(SessionRepository).update(auth.session!.id, {
isPendingSyncReset: true,
});
const resetResponse = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
await ctx.syncAckAll(auth, resetResponse);
const postResetResponse = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
expect(postResetResponse).toEqual([
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});

View file

@ -0,0 +1,114 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { StackRepository } from 'src/repositories/stack.repository';
import { DB } from 'src/schema';
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.StackV1, () => {
it('should detect and sync the first stack', async () => {
const { auth, user, ctx } = await setup();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { stack } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.StacksV1]);
expect(response).toEqual([
{
ack: expect.stringContaining('StackV1'),
data: {
id: stack.id,
createdAt: (stack.createdAt as Date).toISOString(),
updatedAt: (stack.updatedAt as Date).toISOString(),
primaryAssetId: stack.primaryAssetId,
ownerId: stack.ownerId,
},
type: 'StackV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.StacksV1]);
});
it('should detect and sync a deleted stack', async () => {
const { auth, user, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { stack } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]);
await stackRepo.delete(stack.id);
const response = await ctx.syncStream(auth, [SyncRequestType.StacksV1]);
expect(response).toEqual([
{
ack: expect.stringContaining('StackDeleteV1'),
data: { stackId: stack.id },
type: 'StackDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.StacksV1]);
});
it('should sync a stack and then an update to that same stack', async () => {
const { auth, user, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { stack } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.StacksV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.StackV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await stackRepo.update(stack.id, { primaryAssetId: asset2.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.StacksV1]);
expect(newResponse).toEqual([
expect.objectContaining({ type: SyncEntityType.StackV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
expect(newResponse).toEqual([
{
ack: expect.stringContaining('StackV1'),
data: expect.objectContaining({ id: stack.id, primaryAssetId: asset2.id }),
type: 'StackV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.StacksV1]);
});
it('should not sync a stack or stack delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { user: user2 } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user2.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
const { stack } = await ctx.newStack({ ownerId: user2.id }, [asset1.id, asset2.id]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.StacksV1]);
await stackRepo.delete(stack.id);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.StacksV1]);
});
});

View file

@ -0,0 +1,19 @@
import { SyncRequestType } from 'src/enum';
import { SYNC_TYPES_ORDER } from 'src/services/sync.service';
describe('types', () => {
it('should have all the types in the ordering variable', () => {
for (const key in SyncRequestType) {
expect(SYNC_TYPES_ORDER).includes(key);
}
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);
});
});

View file

@ -0,0 +1,125 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType, UserMetadataKey } from 'src/enum';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
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.UserMetadataV1, () => {
it('should detect and sync new user metadata', async () => {
const { auth, user, ctx } = await setup();
const userRepo = ctx.get(UserRepository);
await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.Onboarding, value: { isOnboarded: true } });
const response = await ctx.syncStream(auth, [SyncRequestType.UserMetadataV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: UserMetadataKey.Onboarding,
userId: user.id,
value: { isOnboarded: true },
},
type: 'UserMetadataV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.UserMetadataV1]);
});
it('should update user metadata', async () => {
const { auth, user, ctx } = await setup();
const userRepo = ctx.get(UserRepository);
await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.Onboarding, value: { isOnboarded: true } });
const response = await ctx.syncStream(auth, [SyncRequestType.UserMetadataV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: UserMetadataKey.Onboarding,
userId: user.id,
value: { isOnboarded: true },
},
type: 'UserMetadataV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.Onboarding, value: { isOnboarded: false } });
const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.UserMetadataV1]);
expect(updatedResponse).toEqual([
{
ack: expect.any(String),
data: {
key: UserMetadataKey.Onboarding,
userId: user.id,
value: { isOnboarded: false },
},
type: 'UserMetadataV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, updatedResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.UserMetadataV1]);
});
});
describe(SyncEntityType.UserMetadataDeleteV1, () => {
it('should delete and sync user metadata', async () => {
const { auth, user, ctx } = await setup();
const userRepo = ctx.get(UserRepository);
await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.Onboarding, value: { isOnboarded: true } });
const response = await ctx.syncStream(auth, [SyncRequestType.UserMetadataV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: UserMetadataKey.Onboarding,
userId: user.id,
value: { isOnboarded: true },
},
type: 'UserMetadataV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await userRepo.deleteMetadata(auth.user.id, UserMetadataKey.Onboarding);
await expect(ctx.syncStream(auth, [SyncRequestType.UserMetadataV1])).resolves.toEqual([
{
ack: expect.any(String),
data: {
userId: user.id,
key: UserMetadataKey.Onboarding,
},
type: 'UserMetadataDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});

View file

@ -0,0 +1,137 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
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.UserV1, () => {
it('should detect and sync the first user', async () => {
const { auth, ctx } = await setup(await getKyselyDB());
const userRepo = ctx.get(UserRepository);
const user = await userRepo.get(auth.user.id, { withDeleted: false });
if (!user) {
expect.fail('First user should exist');
}
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
deletedAt: user.deletedAt,
email: user.email,
hasProfileImage: user.profileImagePath !== '',
id: user.id,
name: user.name,
avatarColor: user.avatarColor,
profileChangedAt: user.profileChangedAt.toISOString(),
},
type: 'UserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.UsersV1]);
});
it('should detect and sync a soft deleted user', async () => {
const { auth, ctx } = await setup(await getKyselyDB());
const { user: deleted } = await ctx.newUser({ deletedAt: new Date().toISOString() });
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: expect.objectContaining({ id: auth.user.id }),
type: 'UserV1',
},
{
ack: expect.any(String),
data: expect.objectContaining({ id: deleted.id }),
type: 'UserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]),
);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.UsersV1]);
});
it('should detect and sync a deleted user', async () => {
const { auth, user: authUser, ctx } = await setup(await getKyselyDB());
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser();
await userRepo.delete({ id: user.id }, true);
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
userId: user.id,
},
type: 'UserDeleteV1',
},
{
ack: expect.any(String),
data: expect.objectContaining({ id: authUser.id }),
type: 'UserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.UsersV1]);
});
it('should sync a user and then an update to that same user', async () => {
const { auth, user, ctx } = await setup(await getKyselyDB());
const userRepo = ctx.get(UserRepository);
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: user.id }),
type: 'UserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
const updated = await userRepo.update(auth.user.id, { name: 'new name' });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: user.id, name: updated.name }),
type: 'UserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});