diff --git a/mobile/test/providers/asset_viewer/video_player_ready_test.dart b/mobile/test/providers/asset_viewer/video_player_ready_test.dart new file mode 100644 index 000000000..97315e3ac --- /dev/null +++ b/mobile/test/providers/asset_viewer/video_player_ready_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:native_video_player/native_video_player.dart'; + +class _Controller extends Mock implements NativeVideoPlayerController {} + +class _PlaybackInfo extends Mock implements PlaybackInfo {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const wakelock = BasicMessageChannel( + 'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi.toggle', + StandardMessageCodec(), + ); + late _Controller controller; + late VideoPlayerNotifier notifier; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler( + wakelock, + (_) async => [null], + ); + final playback = _PlaybackInfo(); + when(() => playback.status).thenReturn(PlaybackStatus.stopped); + when(() => playback.position).thenReturn(0); + controller = _Controller(); + when(() => controller.playbackInfo).thenReturn(playback); + when(() => controller.videoInfo).thenReturn(VideoInfo.fromJson({'width': 1920, 'height': 1080, 'duration': 5000})); + when(() => controller.play()).thenAnswer((_) async {}); + notifier = VideoPlayerNotifier()..attachController(controller); + }); + + tearDown(() async { + notifier.dispose(); + await Future.delayed(Duration.zero); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler( + wakelock, + null, + ); + }); + + test('newly loaded stopped video offers playback', () { + notifier.onNativePlaybackReady(); + expect(notifier.state.status, VideoPlaybackStatus.paused); + expect(notifier.state.duration, const Duration(seconds: 5)); + }); + + testWidgets('stopped ready event cancels the pending buffering indicator', (tester) async { + await notifier.play(); + notifier.onNativePlaybackReady(); + await tester.pump(const Duration(seconds: 2)); + expect(notifier.state.status, VideoPlaybackStatus.paused); + }); + + test('ready event preserves replay after playback has completed', () { + notifier.onNativeStatusChanged(); + expect(notifier.state.status, VideoPlaybackStatus.completed); + notifier.onNativePlaybackReady(); + expect(notifier.state.status, VideoPlaybackStatus.completed); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9589de5d..6b134ecf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ overrides: sharp: ^0.35.3 tsc-alias: 1.9.2 -packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k= +packageExtensionsChecksum: sha256-y1PgMgGqpI3vgcVUp03tHO1Ia2xP1NSL0js1RewfUP0= pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA= @@ -939,7 +939,7 @@ importers: version: 10.6.0 '@koddsson/eslint-plugin-tscompat': specifier: ^0.2.0 - version: 0.2.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 0.2.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) '@socket.io/component-emitter': specifier: ^3.1.0 version: 3.1.2 @@ -16646,16 +16646,16 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@koddsson/eslint-plugin-tscompat@0.2.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@mdn/browser-compat-data': 6.1.5 '@typescript-eslint/type-utils': 8.70.1(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) '@typescript-eslint/utils': 8.70.1(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) browserslist: 4.29.0 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - eslint - supports-color - - typescript '@kwsites/file-exists@1.1.1(supports-color@8.1.1)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a6a0aef84..9a22c9e7d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,9 @@ overrides: # 1.9.3-1.9.5 are confirmed broken tsc-alias: 1.9.2 packageExtensions: + '@koddsson/eslint-plugin-tscompat': + dependencies: + typescript: 'npm:@typescript/typescript6@^6.0.2' nestjs-kysely: dependencies: tslib: '*' diff --git a/server/test/medium/specs/repositories/shared-link-access.repository.spec.ts b/server/test/medium/specs/repositories/shared-link-access.repository.spec.ts new file mode 100644 index 000000000..db963bae2 --- /dev/null +++ b/server/test/medium/specs/repositories/shared-link-access.repository.spec.ts @@ -0,0 +1,55 @@ +import { Kysely } from 'kysely'; +import { randomBytes } from 'node:crypto'; +import { AssetType, SharedLinkType } from 'src/enum.js'; +import { AccessRepository } from 'src/repositories/access.repository.js'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository.js'; +import { DB } from 'src/schema/index.js'; +import { BaseService } from 'src/services/base.service.js'; +import { newMediumService } from 'test/medium.factory.js'; +import { factory } from 'test/small.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +let db: Kysely; + +beforeAll(async () => { + db = await getKyselyDB(); +}); + +afterAll(async () => { + await db.destroy(); +}); + +describe('shared link asset access', () => { + for (const type of [SharedLinkType.Album, SharedLinkType.Individual]) { + it(`${type}: permits the photo and its Live Photo video, but excludes unrelated and trashed assets`, async () => { + const { ctx } = newMediumService(BaseService, { database: db, real: [], mock: [LoggingRepository] }); + const { user } = await ctx.newUser(); + const { asset: video } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video }); + const { asset: photo } = await ctx.newAsset({ ownerId: user.id, livePhotoVideoId: video.id }); + const { asset: unrelated } = await ctx.newAsset({ ownerId: user.id }); + const { asset: trashed } = await ctx.newAsset({ ownerId: user.id, deletedAt: new Date().toISOString() }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: photo.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: trashed.id }); + const link = await ctx.get(SharedLinkRepository).create({ + id: factory.uuid(), + key: randomBytes(16), + userId: user.id, + allowUpload: false, + type, + ...(type === SharedLinkType.Album ? { albumId: album.id } : { assetIds: [photo.id, trashed.id] }), + }); + const access = ctx.get(AccessRepository).asset; + const requested = new Set([photo.id, video.id, unrelated.id, trashed.id]); + await expect(access.checkSharedLinkAccess(link.id, requested)).resolves.toEqual(new Set([photo.id, video.id])); + await expect(access.checkSharedLinkAccess(factory.uuid(), requested)).resolves.toEqual(new Set()); + await expect(access.checkSharedLinkAccess(link.id, new Set())).resolves.toEqual(new Set()); + if (type !== SharedLinkType.Album) { + return; + } + await db.updateTable('album').set({ deletedAt: new Date().toISOString() }).where('id', '=', album.id).execute(); + await expect(access.checkSharedLinkAccess(link.id, requested)).resolves.toEqual(new Set()); + }); + } +});