A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: remove AppController and AppService, update AppModule to eliminate unused components, and enhance MoviesController and MoviesService with improved session handling and new features for movie tracking

+960 -76
-22
backend/src/app.controller.spec.ts
··· 1 - import { Test, TestingModule } from '@nestjs/testing'; 2 - import { AppController } from './app.controller'; 3 - import { AppService } from './app.service'; 4 - 5 - describe('AppController', () => { 6 - let appController: AppController; 7 - 8 - beforeEach(async () => { 9 - const app: TestingModule = await Test.createTestingModule({ 10 - controllers: [AppController], 11 - providers: [AppService], 12 - }).compile(); 13 - 14 - appController = app.get<AppController>(AppController); 15 - }); 16 - 17 - describe('root', () => { 18 - it('should return "Hello World!"', () => { 19 - expect(appController.getHello()).toBe('Hello World!'); 20 - }); 21 - }); 22 - });
-12
backend/src/app.controller.ts
··· 1 - import { Controller, Get } from '@nestjs/common'; 2 - import { AppService } from './app.service'; 3 - 4 - @Controller() 5 - export class AppController { 6 - constructor(private readonly appService: AppService) {} 7 - 8 - @Get() 9 - getHello(): string { 10 - return this.appService.getHello(); 11 - } 12 - }
-4
backend/src/app.module.ts
··· 1 1 import { Module } from '@nestjs/common'; 2 - import { AppController } from './app.controller'; 3 - import { AppService } from './app.service'; 4 2 import { PrismaModule } from './prisma/prisma.module'; 5 3 import { MoviesModule } from './movies/movies.module'; 6 4 import { AuthModule } from './auth/auth.module'; ··· 15 13 AuthModule, 16 14 IngesterModule, 17 15 ], 18 - controllers: [AppController], 19 - providers: [AppService], 20 16 }) 21 17 export class AppModule {}
-8
backend/src/app.service.ts
··· 1 - import { Injectable } from '@nestjs/common'; 2 - 3 - @Injectable() 4 - export class AppService { 5 - getHello(): string { 6 - return 'Hello World!'; 7 - } 8 - }
+37 -13
backend/src/auth/auth.controller.spec.ts
··· 59 59 return res; 60 60 }; 61 61 62 - const createMockRequest = (overrides: Partial<Request> = {}) => { 62 + const createMockRequest = ( 63 + overrides: Partial<import('express').Request> = {}, 64 + ) => { 63 65 return { 64 66 url: '/auth/callback', 65 67 cookies: {}, 66 68 ...overrides, 67 - } as unknown as Request; 69 + } as unknown as import('express').Request; 68 70 }; 69 71 70 72 beforeEach(async () => { ··· 275 277 }; 276 278 mockAuthService.getUser.mockResolvedValue(mockUser); 277 279 278 - const req = createMockRequest(); 279 - (req as any).user = { did: 'did:plc:abc123' }; 280 + const req = createMockRequest({ 281 + user: { did: 'did:plc:abc123', session: {} }, 282 + } as unknown as import('express').Request); 280 283 281 - const result = await controller.me(req); 284 + const result = await controller.me( 285 + req as unknown as import('../auth/types').AuthenticatedRequest, 286 + ); 282 287 283 288 expect(result).toEqual(mockUser); 284 289 expect(mockAuthService.getUser).toHaveBeenCalledWith('did:plc:abc123'); ··· 287 292 it('should throw BadRequestException when no user in request', async () => { 288 293 const req = createMockRequest(); 289 294 290 - await expect(controller.me(req)).rejects.toThrow(BadRequestException); 295 + await expect( 296 + controller.me( 297 + req as unknown as import('../auth/types').AuthenticatedRequest, 298 + ), 299 + ).rejects.toThrow(BadRequestException); 291 300 }); 292 301 293 302 it('should throw BadRequestException when user not found in DB', async () => { 294 303 mockAuthService.getUser.mockResolvedValue(null); 295 304 296 - const req = createMockRequest(); 297 - (req as any).user = { did: 'did:plc:abc123' }; 305 + const req = createMockRequest({ 306 + user: { did: 'did:plc:abc123', session: {} }, 307 + } as unknown as import('express').Request); 298 308 299 - await expect(controller.me(req)).rejects.toThrow(BadRequestException); 309 + await expect( 310 + controller.me( 311 + req as unknown as import('../auth/types').AuthenticatedRequest, 312 + ), 313 + ).rejects.toThrow(BadRequestException); 300 314 }); 301 315 }); 302 316 ··· 304 318 it('should revoke session and clear cookie', async () => { 305 319 const req = createMockRequest({ 306 320 cookies: { session: 'session-123' }, 307 - }); 321 + user: { did: 'did:plc:abc123', session: {} }, 322 + } as unknown as import('express').Request); 308 323 const res = createMockResponse(); 309 324 310 - await controller.logout(req, res); 325 + await controller.logout( 326 + req as unknown as import('../auth/types').AuthenticatedRequest, 327 + res, 328 + ); 311 329 312 330 expect(mockAuthService.revokeBySessionId).toHaveBeenCalledWith( 313 331 'session-123', ··· 327 345 }); 328 346 329 347 it('should still clear cookie when no session exists', async () => { 330 - const req = createMockRequest({ cookies: {} }); 348 + const req = createMockRequest({ 349 + cookies: {}, 350 + user: { did: 'did:plc:abc123', session: {} }, 351 + } as unknown as import('express').Request); 331 352 const res = createMockResponse(); 332 353 333 - await controller.logout(req, res); 354 + await controller.logout( 355 + req as unknown as import('../auth/types').AuthenticatedRequest, 356 + res, 357 + ); 334 358 335 359 expect(mockAuthService.revokeBySessionId).not.toHaveBeenCalled(); 336 360 expect(res.clearCookie).toHaveBeenCalled();
+3 -3
backend/src/auth/auth.controller.ts
··· 12 12 } from '@nestjs/common'; 13 13 import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; 14 14 import { ConfigService } from '@nestjs/config'; 15 - import type { Response } from 'express'; 15 + import type { Request, Response } from 'express'; 16 16 import { AuthService } from './auth.service'; 17 17 import { AuthGuard } from './auth.guard'; 18 18 import { UserDto } from './dto/user.dto'; 19 - import { AuthenticatedRequest } from './types'; 19 + import type { AuthenticatedRequest } from './types'; 20 20 21 21 const SESSION_COOKIE_NAME = 'session'; 22 22 const PLATFORM_COOKIE_NAME = 'auth_platform'; ··· 117 117 status: 302, 118 118 description: 'Redirect to frontend after authentication', 119 119 }) 120 - async callback(@Req() req: Request, @Res() res: Response) { 120 + async callback(@Req() req: import('express').Request, @Res() res: Response) { 121 121 const frontendUrl = 122 122 this.configService.get<string>('FRONTEND_URL') || 'http://127.0.0.1:3000'; 123 123 const isProduction =
+540
backend/src/ingester/ingester.service.spec.ts
··· 1 + import { Test, TestingModule } from '@nestjs/testing'; 2 + import { ConfigService } from '@nestjs/config'; 3 + 4 + // Mock PrismaService before importing 5 + jest.mock('../prisma/prisma.service', () => ({ 6 + PrismaService: jest.fn(), 7 + })); 8 + 9 + // Mock @atproto modules 10 + jest.mock('@atproto/sync', () => ({ 11 + Firehose: jest.fn().mockImplementation(() => ({ 12 + start: jest.fn().mockResolvedValue(undefined), 13 + destroy: jest.fn().mockResolvedValue(undefined), 14 + })), 15 + })); 16 + 17 + jest.mock('@atproto/identity', () => ({ 18 + IdResolver: jest.fn().mockImplementation(() => ({ 19 + resolve: jest.fn(), 20 + })), 21 + })); 22 + 23 + import { IngesterService } from './ingester.service'; 24 + import { PrismaService } from '../prisma/prisma.service'; 25 + import { Firehose } from '@atproto/sync'; 26 + 27 + type FirehoseEvent = { 28 + event: string; 29 + collection: string; 30 + record?: { 31 + $type: string; 32 + movieId?: string; 33 + source?: string; 34 + watchedAt?: string; 35 + createdAt?: string; 36 + }; 37 + uri: { 38 + toString: () => string; 39 + }; 40 + rkey: string; 41 + cid?: { toString: () => string }; 42 + author?: string; 43 + }; 44 + 45 + type HandleEventCallback = (event: FirehoseEvent) => Promise<void>; 46 + type OnErrorCallback = (err: { message: string }) => void; 47 + 48 + describe('IngesterService', () => { 49 + let service: IngesterService; 50 + let mockPrismaService: jest.Mocked<PrismaService>; 51 + let mockFirehoseInstance: { start: jest.Mock; destroy: jest.Mock }; 52 + 53 + const mockConfigService = { 54 + get: jest.fn((key: string) => { 55 + if (key === 'ATPROTO_RELAY_URL') return 'wss://test.relay'; 56 + return undefined; 57 + }), 58 + }; 59 + 60 + beforeEach(async () => { 61 + jest.clearAllMocks(); 62 + mockFirehoseInstance = { 63 + start: jest.fn().mockResolvedValue(undefined), 64 + destroy: jest.fn().mockResolvedValue(undefined), 65 + }; 66 + (Firehose as jest.Mock).mockImplementation(() => mockFirehoseInstance); 67 + 68 + mockPrismaService = { 69 + user: { 70 + findUnique: jest.fn(), 71 + }, 72 + trackedMovie: { 73 + upsert: jest.fn(), 74 + deleteMany: jest.fn(), 75 + }, 76 + } as unknown as jest.Mocked<PrismaService>; 77 + 78 + const module: TestingModule = await Test.createTestingModule({ 79 + providers: [ 80 + IngesterService, 81 + { provide: PrismaService, useValue: mockPrismaService }, 82 + { provide: ConfigService, useValue: mockConfigService }, 83 + ], 84 + }).compile(); 85 + 86 + service = module.get<IngesterService>(IngesterService); 87 + }); 88 + 89 + describe('onModuleInit', () => { 90 + it('should start the firehose ingester', async () => { 91 + service.onModuleInit(); 92 + // Allow async operations to complete 93 + await new Promise((resolve) => setTimeout(resolve, 10)); 94 + 95 + expect(Firehose).toHaveBeenCalledWith( 96 + expect.objectContaining({ 97 + filterCollections: ['app.opnshelf.movie'], 98 + }), 99 + ); 100 + expect(mockFirehoseInstance.start).toHaveBeenCalled(); 101 + }); 102 + }); 103 + 104 + describe('onModuleDestroy', () => { 105 + it('should stop the firehose ingester', async () => { 106 + service.onModuleInit(); 107 + await new Promise((resolve) => setTimeout(resolve, 10)); 108 + 109 + service.onModuleDestroy(); 110 + 111 + expect(mockFirehoseInstance.destroy).toHaveBeenCalled(); 112 + }); 113 + 114 + it('should handle destroy when firehose is not initialized', () => { 115 + expect(() => service.onModuleDestroy()).not.toThrow(); 116 + }); 117 + }); 118 + 119 + describe('handleEvent - create', () => { 120 + it('should upsert tracked movie for existing user', async () => { 121 + const mockUser = { did: 'did:plc:abc123', handle: 'test.bsky.social' }; 122 + mockPrismaService.user.findUnique.mockResolvedValue(mockUser as any); 123 + mockPrismaService.trackedMovie.upsert.mockResolvedValue({} as any); 124 + 125 + const createEvent = { 126 + event: 'create', 127 + collection: 'app.opnshelf.movie', 128 + record: { 129 + $type: 'app.opnshelf.movie', 130 + movieId: '123', 131 + source: 'tmdb', 132 + watchedAt: '2024-01-15T10:00:00Z', 133 + createdAt: '2024-01-15T10:00:00Z', 134 + }, 135 + uri: { 136 + toString: () => 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 137 + }, 138 + rkey: 'movie-123', 139 + cid: { toString: () => 'cid123' }, 140 + author: 'did:plc:abc123', 141 + }; 142 + 143 + // Trigger the handleEvent through the Firehose constructor callback 144 + let handleEventCallback: HandleEventCallback | undefined; 145 + (Firehose as jest.Mock).mockImplementation((config: any) => { 146 + handleEventCallback = config.handleEvent; 147 + return mockFirehoseInstance; 148 + }); 149 + 150 + service.onModuleInit(); 151 + await new Promise((resolve) => setTimeout(resolve, 10)); 152 + 153 + if (handleEventCallback) { 154 + await handleEventCallback(createEvent); 155 + } 156 + 157 + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ 158 + where: { did: 'did:plc:abc123' }, 159 + }); 160 + expect(mockPrismaService.trackedMovie.upsert).toHaveBeenCalledWith({ 161 + where: { uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-123' }, 162 + create: expect.objectContaining({ 163 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 164 + rkey: 'movie-123', 165 + cid: 'cid123', 166 + userDid: 'did:plc:abc123', 167 + movieId: '123', 168 + status: 'watched', 169 + }), 170 + update: expect.objectContaining({ 171 + cid: 'cid123', 172 + status: 'watched', 173 + }), 174 + }); 175 + }); 176 + 177 + it('should skip records for non-existent users', async () => { 178 + mockPrismaService.user.findUnique.mockResolvedValue(null); 179 + 180 + const createEvent = { 181 + event: 'create', 182 + collection: 'app.opnshelf.movie', 183 + record: { 184 + $type: 'app.opnshelf.movie', 185 + movieId: '123', 186 + source: 'tmdb', 187 + watchedAt: '2024-01-15T10:00:00Z', 188 + createdAt: '2024-01-15T10:00:00Z', 189 + }, 190 + uri: { 191 + toString: () => 'at://did:plc:unknown/app.opnshelf.movie/movie-123', 192 + }, 193 + rkey: 'movie-123', 194 + cid: { toString: () => 'cid123' }, 195 + author: 'did:plc:unknown', 196 + }; 197 + 198 + let handleEventCallback: HandleEventCallback | undefined; 199 + (Firehose as jest.Mock).mockImplementation((config: any) => { 200 + handleEventCallback = config.handleEvent; 201 + return mockFirehoseInstance; 202 + }); 203 + 204 + service.onModuleInit(); 205 + await new Promise((resolve) => setTimeout(resolve, 10)); 206 + 207 + if (handleEventCallback) { 208 + await handleEventCallback(createEvent); 209 + } 210 + 211 + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ 212 + where: { did: 'did:plc:unknown' }, 213 + }); 214 + expect(mockPrismaService.trackedMovie.upsert).not.toHaveBeenCalled(); 215 + }); 216 + 217 + it('should skip invalid movie records', async () => { 218 + const createEvent = { 219 + event: 'create', 220 + collection: 'app.opnshelf.movie', 221 + record: { 222 + $type: 'app.opnshelf.movie', 223 + // Missing required fields 224 + }, 225 + uri: { 226 + toString: () => 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 227 + }, 228 + rkey: 'movie-123', 229 + cid: { toString: () => 'cid123' }, 230 + author: 'did:plc:abc123', 231 + }; 232 + 233 + let handleEventCallback: HandleEventCallback | undefined; 234 + (Firehose as jest.Mock).mockImplementation((config: any) => { 235 + handleEventCallback = config.handleEvent; 236 + return mockFirehoseInstance; 237 + }); 238 + 239 + service.onModuleInit(); 240 + await new Promise((resolve) => setTimeout(resolve, 10)); 241 + 242 + if (handleEventCallback) { 243 + await handleEventCallback(createEvent); 244 + } 245 + 246 + expect(mockPrismaService.user.findUnique).not.toHaveBeenCalled(); 247 + expect(mockPrismaService.trackedMovie.upsert).not.toHaveBeenCalled(); 248 + }); 249 + 250 + it('should skip events for other collections', async () => { 251 + const createEvent = { 252 + event: 'create', 253 + collection: 'app.bsky.feed.post', 254 + record: { $type: 'app.bsky.feed.post' }, 255 + uri: { toString: () => 'at://did:plc:abc123/app.bsky.feed.post/abc' }, 256 + rkey: 'abc', 257 + cid: { toString: () => 'cid123' }, 258 + author: 'did:plc:abc123', 259 + }; 260 + 261 + let handleEventCallback: HandleEventCallback | undefined; 262 + (Firehose as jest.Mock).mockImplementation((config: any) => { 263 + handleEventCallback = config.handleEvent; 264 + return mockFirehoseInstance; 265 + }); 266 + 267 + service.onModuleInit(); 268 + await new Promise((resolve) => setTimeout(resolve, 10)); 269 + 270 + if (handleEventCallback) { 271 + await handleEventCallback(createEvent); 272 + } 273 + 274 + expect(mockPrismaService.user.findUnique).not.toHaveBeenCalled(); 275 + }); 276 + 277 + it('should extract DID from URI when author is not provided', async () => { 278 + const mockUser = { did: 'did:plc:abc123', handle: 'test.bsky.social' }; 279 + mockPrismaService.user.findUnique.mockResolvedValue(mockUser as any); 280 + mockPrismaService.trackedMovie.upsert.mockResolvedValue({} as any); 281 + 282 + const createEvent = { 283 + event: 'create', 284 + collection: 'app.opnshelf.movie', 285 + record: { 286 + $type: 'app.opnshelf.movie', 287 + movieId: '456', 288 + source: 'tmdb', 289 + watchedAt: '2024-01-15T10:00:00Z', 290 + createdAt: '2024-01-15T10:00:00Z', 291 + }, 292 + uri: { 293 + toString: () => 'at://did:plc:abc123/app.opnshelf.movie/movie-456', 294 + }, 295 + rkey: 'movie-456', 296 + cid: { toString: () => 'cid456' }, 297 + // No author field 298 + }; 299 + 300 + let handleEventCallback: HandleEventCallback | undefined; 301 + (Firehose as jest.Mock).mockImplementation((config: any) => { 302 + handleEventCallback = config.handleEvent; 303 + return mockFirehoseInstance; 304 + }); 305 + 306 + service.onModuleInit(); 307 + await new Promise((resolve) => setTimeout(resolve, 10)); 308 + 309 + if (handleEventCallback) { 310 + await handleEventCallback(createEvent); 311 + } 312 + 313 + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ 314 + where: { did: 'did:plc:abc123' }, 315 + }); 316 + }); 317 + 318 + it('should skip when author cannot be determined', async () => { 319 + const createEvent = { 320 + event: 'create', 321 + collection: 'app.opnshelf.movie', 322 + record: { 323 + $type: 'app.opnshelf.movie', 324 + movieId: '123', 325 + source: 'tmdb', 326 + watchedAt: '2024-01-15T10:00:00Z', 327 + createdAt: '2024-01-15T10:00:00Z', 328 + }, 329 + uri: { toString: () => 'invalid-uri' }, 330 + rkey: 'movie-123', 331 + cid: { toString: () => 'cid123' }, 332 + // No author field and invalid URI 333 + }; 334 + 335 + let handleEventCallback: HandleEventCallback | undefined; 336 + (Firehose as jest.Mock).mockImplementation((config: any) => { 337 + handleEventCallback = config.handleEvent; 338 + return mockFirehoseInstance; 339 + }); 340 + 341 + service.onModuleInit(); 342 + await new Promise((resolve) => setTimeout(resolve, 10)); 343 + 344 + if (handleEventCallback) { 345 + await handleEventCallback(createEvent); 346 + } 347 + 348 + expect(mockPrismaService.user.findUnique).not.toHaveBeenCalled(); 349 + }); 350 + }); 351 + 352 + describe('handleEvent - update', () => { 353 + it('should handle update events same as create', async () => { 354 + const mockUser = { did: 'did:plc:abc123', handle: 'test.bsky.social' }; 355 + mockPrismaService.user.findUnique.mockResolvedValue(mockUser as any); 356 + mockPrismaService.trackedMovie.upsert.mockResolvedValue({} as any); 357 + 358 + const updateEvent = { 359 + event: 'update', 360 + collection: 'app.opnshelf.movie', 361 + record: { 362 + $type: 'app.opnshelf.movie', 363 + movieId: '789', 364 + source: 'tmdb', 365 + watchedAt: '2024-02-20T15:30:00Z', 366 + createdAt: '2024-01-15T10:00:00Z', 367 + }, 368 + uri: { 369 + toString: () => 'at://did:plc:abc123/app.opnshelf.movie/movie-789', 370 + }, 371 + rkey: 'movie-789', 372 + cid: { toString: () => 'cid789-updated' }, 373 + author: 'did:plc:abc123', 374 + }; 375 + 376 + let handleEventCallback: HandleEventCallback | undefined; 377 + (Firehose as jest.Mock).mockImplementation((config: any) => { 378 + handleEventCallback = config.handleEvent; 379 + return mockFirehoseInstance; 380 + }); 381 + 382 + service.onModuleInit(); 383 + await new Promise((resolve) => setTimeout(resolve, 10)); 384 + 385 + if (handleEventCallback) { 386 + await handleEventCallback(updateEvent); 387 + } 388 + 389 + expect(mockPrismaService.trackedMovie.upsert).toHaveBeenCalledWith( 390 + expect.objectContaining({ 391 + where: { uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-789' }, 392 + update: expect.objectContaining({ 393 + cid: 'cid789-updated', 394 + }), 395 + }), 396 + ); 397 + }); 398 + }); 399 + 400 + describe('handleEvent - delete', () => { 401 + it('should delete tracked movie record', async () => { 402 + mockPrismaService.trackedMovie.deleteMany.mockResolvedValue({ 403 + count: 1, 404 + } as any); 405 + 406 + const deleteEvent = { 407 + event: 'delete', 408 + collection: 'app.opnshelf.movie', 409 + uri: { 410 + toString: () => 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 411 + }, 412 + rkey: 'movie-123', 413 + author: 'did:plc:abc123', 414 + }; 415 + 416 + let handleEventCallback: HandleEventCallback | undefined; 417 + (Firehose as jest.Mock).mockImplementation((config: any) => { 418 + handleEventCallback = config.handleEvent; 419 + return mockFirehoseInstance; 420 + }); 421 + 422 + service.onModuleInit(); 423 + await new Promise((resolve) => setTimeout(resolve, 10)); 424 + 425 + if (handleEventCallback) { 426 + await handleEventCallback(deleteEvent); 427 + } 428 + 429 + expect(mockPrismaService.trackedMovie.deleteMany).toHaveBeenCalledWith({ 430 + where: { uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-123' }, 431 + }); 432 + }); 433 + 434 + it('should skip delete events for other collections', async () => { 435 + const deleteEvent = { 436 + event: 'delete', 437 + collection: 'app.bsky.feed.post', 438 + uri: { toString: () => 'at://did:plc:abc123/app.bsky.feed.post/abc' }, 439 + rkey: 'abc', 440 + author: 'did:plc:abc123', 441 + }; 442 + 443 + let handleEventCallback: HandleEventCallback | undefined; 444 + (Firehose as jest.Mock).mockImplementation((config: any) => { 445 + handleEventCallback = config.handleEvent; 446 + return mockFirehoseInstance; 447 + }); 448 + 449 + service.onModuleInit(); 450 + await new Promise((resolve) => setTimeout(resolve, 10)); 451 + 452 + if (handleEventCallback) { 453 + await handleEventCallback(deleteEvent); 454 + } 455 + 456 + expect(mockPrismaService.trackedMovie.deleteMany).not.toHaveBeenCalled(); 457 + }); 458 + 459 + it('should skip delete when URI is missing', async () => { 460 + const deleteEvent = { 461 + event: 'delete', 462 + collection: 'app.opnshelf.movie', 463 + // No uri field 464 + rkey: 'movie-123', 465 + author: 'did:plc:abc123', 466 + }; 467 + 468 + let handleEventCallback: HandleEventCallback | undefined; 469 + (Firehose as jest.Mock).mockImplementation((config: any) => { 470 + handleEventCallback = config.handleEvent; 471 + return mockFirehoseInstance; 472 + }); 473 + 474 + service.onModuleInit(); 475 + await new Promise((resolve) => setTimeout(resolve, 10)); 476 + 477 + if (handleEventCallback) { 478 + await handleEventCallback(deleteEvent); 479 + } 480 + 481 + expect(mockPrismaService.trackedMovie.deleteMany).not.toHaveBeenCalled(); 482 + }); 483 + }); 484 + 485 + describe('error handling', () => { 486 + it('should handle errors in event handler gracefully', async () => { 487 + mockPrismaService.user.findUnique.mockRejectedValue( 488 + new Error('DB error'), 489 + ); 490 + 491 + const createEvent = { 492 + event: 'create', 493 + collection: 'app.opnshelf.movie', 494 + record: { 495 + $type: 'app.opnshelf.movie', 496 + movieId: '123', 497 + source: 'tmdb', 498 + watchedAt: '2024-01-15T10:00:00Z', 499 + createdAt: '2024-01-15T10:00:00Z', 500 + }, 501 + uri: { 502 + toString: () => 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 503 + }, 504 + rkey: 'movie-123', 505 + cid: { toString: () => 'cid123' }, 506 + author: 'did:plc:abc123', 507 + }; 508 + 509 + let handleEventCallback: HandleEventCallback | undefined; 510 + (Firehose as jest.Mock).mockImplementation((config: any) => { 511 + handleEventCallback = config.handleEvent; 512 + return mockFirehoseInstance; 513 + }); 514 + 515 + service.onModuleInit(); 516 + await new Promise((resolve) => setTimeout(resolve, 10)); 517 + 518 + // Should not throw 519 + if (handleEventCallback) { 520 + await expect(handleEventCallback(createEvent)).resolves.not.toThrow(); 521 + } 522 + }); 523 + 524 + it('should call onError callback for firehose errors', async () => { 525 + let onErrorCallback: OnErrorCallback | undefined; 526 + (Firehose as jest.Mock).mockImplementation((config: any) => { 527 + onErrorCallback = config.onError; 528 + return mockFirehoseInstance; 529 + }); 530 + 531 + service.onModuleInit(); 532 + await new Promise((resolve) => setTimeout(resolve, 10)); 533 + 534 + // Should not throw when onError is called 535 + if (onErrorCallback) { 536 + expect(() => onErrorCallback({ message: 'Test error' })).not.toThrow(); 537 + } 538 + }); 539 + }); 540 + });
+5 -1
backend/src/ingester/ingester.service.ts
··· 173 173 if (evt.event === 'delete') { 174 174 if (evt.collection !== COLLECTION) return; 175 175 176 - const uri = evt.uri.toString(); 176 + const uri = evt.uri?.toString(); 177 + if (!uri) { 178 + this.logger.warn('Delete event missing URI, skipping'); 179 + return; 180 + } 177 181 this.logger.log(`Removing movie record: ${uri}`); 178 182 179 183 await this.prisma.trackedMovie.deleteMany({
+160 -1
backend/src/movies/movies.controller.spec.ts
··· 1 1 import { Test, TestingModule } from '@nestjs/testing'; 2 + import { AuthGuard } from '../auth/auth.guard'; 3 + import { AuthService } from '../auth/auth.service'; 4 + import type { AuthenticatedRequest } from '../auth/types'; 2 5 3 6 // Mock PrismaService before importing 4 7 jest.mock('../prisma/prisma.service', () => ({ 5 8 PrismaService: jest.fn(), 6 9 })); 7 10 11 + // Mock @atproto modules to prevent import errors 12 + jest.mock('@atproto/oauth-client-node', () => ({})); 13 + jest.mock('@atproto/api', () => ({})); 14 + 8 15 import { MoviesController } from './movies.controller'; 9 16 import { MoviesService } from './movies.service'; 10 17 ··· 17 24 getMovieDetails: jest.fn(), 18 25 getUserMovies: jest.fn(), 19 26 getMovieByTMDBId: jest.fn(), 27 + markWatched: jest.fn(), 28 + indexTrackedMovie: jest.fn(), 29 + unmarkWatched: jest.fn(), 30 + removeTrackedMovie: jest.fn(), 31 + }; 32 + 33 + const mockAuthService = { 34 + getUser: jest.fn(), 35 + revokeBySessionId: jest.fn(), 20 36 }; 21 37 22 38 beforeEach(async () => { ··· 24 40 25 41 const module: TestingModule = await Test.createTestingModule({ 26 42 controllers: [MoviesController], 27 - providers: [{ provide: MoviesService, useValue: mockMoviesService }], 43 + providers: [ 44 + { provide: MoviesService, useValue: mockMoviesService }, 45 + { provide: AuthService, useValue: mockAuthService }, 46 + AuthGuard, 47 + ], 28 48 }).compile(); 29 49 30 50 controller = module.get<MoviesController>(MoviesController); ··· 158 178 const result = await controller.getMovie('999'); 159 179 160 180 expect(result).toBeNull(); 181 + }); 182 + }); 183 + 184 + const createMockRequest = (user: { 185 + did: string; 186 + session: { did: string }; 187 + }): AuthenticatedRequest => { 188 + return { user } as unknown as AuthenticatedRequest; 189 + }; 190 + 191 + describe('markWatched', () => { 192 + it('should mark movie as watched and return tracked movie', async () => { 193 + const mockUser = { 194 + did: 'did:plc:abc123', 195 + session: { did: 'did:plc:abc123' }, 196 + }; 197 + const mockMarkWatchedResult = { 198 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-456', 199 + cid: 'cid456', 200 + rkey: 'movie-456', 201 + record: { 202 + watchedAt: '2024-01-15T10:00:00Z', 203 + }, 204 + }; 205 + const mockTrackedMovie = { 206 + id: 'tracked-1', 207 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-456', 208 + rkey: 'movie-456', 209 + cid: 'cid456', 210 + userDid: 'did:plc:abc123', 211 + movieId: '456', 212 + status: 'watched', 213 + watchedDate: new Date('2024-01-15'), 214 + movie: { 215 + movieId: '456', 216 + title: 'Test Movie', 217 + }, 218 + }; 219 + 220 + mockMoviesService.markWatched.mockResolvedValue(mockMarkWatchedResult); 221 + mockMoviesService.indexTrackedMovie.mockResolvedValue(mockTrackedMovie); 222 + 223 + const req = createMockRequest(mockUser); 224 + const result = await controller.markWatched({ movieId: '456' }, req); 225 + 226 + expect(mockMoviesService.markWatched).toHaveBeenCalledWith( 227 + 'did:plc:abc123', 228 + mockUser.session, 229 + '456', 230 + ); 231 + expect(mockMoviesService.indexTrackedMovie).toHaveBeenCalledWith( 232 + 'at://did:plc:abc123/app.opnshelf.movie/movie-456', 233 + 'cid456', 234 + 'movie-456', 235 + 'did:plc:abc123', 236 + '456', 237 + '2024-01-15T10:00:00Z', 238 + ); 239 + expect(result).toEqual(mockTrackedMovie); 240 + }); 241 + 242 + it('should return minimal response when optimistic update fails', async () => { 243 + const mockUser = { 244 + did: 'did:plc:abc123', 245 + session: { did: 'did:plc:abc123' }, 246 + }; 247 + const mockMarkWatchedResult = { 248 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-789', 249 + cid: 'cid789', 250 + rkey: 'movie-789', 251 + record: { 252 + watchedAt: '2024-01-20T15:30:00Z', 253 + }, 254 + }; 255 + 256 + mockMoviesService.markWatched.mockResolvedValue(mockMarkWatchedResult); 257 + mockMoviesService.indexTrackedMovie.mockRejectedValue( 258 + new Error('DB error'), 259 + ); 260 + 261 + const req = createMockRequest(mockUser); 262 + const result = await controller.markWatched({ movieId: '789' }, req); 263 + 264 + expect(result).toEqual({ 265 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-789', 266 + cid: 'cid789', 267 + rkey: 'movie-789', 268 + movieId: '789', 269 + userDid: 'did:plc:abc123', 270 + }); 271 + }); 272 + }); 273 + 274 + describe('unmarkWatched', () => { 275 + it('should unmark movie as watched', async () => { 276 + const mockUser = { 277 + did: 'did:plc:abc123', 278 + session: { did: 'did:plc:abc123' }, 279 + }; 280 + 281 + mockMoviesService.unmarkWatched.mockResolvedValue({ 282 + rkey: 'movie-123', 283 + movieId: '123', 284 + }); 285 + mockMoviesService.removeTrackedMovie.mockResolvedValue({ 286 + count: 1, 287 + } as unknown as ReturnType<typeof mockMoviesService.removeTrackedMovie>); 288 + 289 + const req = createMockRequest(mockUser); 290 + await controller.unmarkWatched('123', req); 291 + 292 + expect(mockMoviesService.unmarkWatched).toHaveBeenCalledWith( 293 + 'did:plc:abc123', 294 + mockUser.session, 295 + '123', 296 + ); 297 + expect(mockMoviesService.removeTrackedMovie).toHaveBeenCalledWith( 298 + 'did:plc:abc123', 299 + '123', 300 + ); 301 + }); 302 + 303 + it('should handle failure when removing from local DB', async () => { 304 + const mockUser = { 305 + did: 'did:plc:abc123', 306 + session: { did: 'did:plc:abc123' }, 307 + }; 308 + 309 + mockMoviesService.unmarkWatched.mockResolvedValue({ 310 + rkey: 'movie-456', 311 + movieId: '456', 312 + }); 313 + mockMoviesService.removeTrackedMovie.mockRejectedValue( 314 + new Error('DB error'), 315 + ); 316 + 317 + const req = createMockRequest(mockUser); 318 + // Should not throw 319 + await expect(controller.unmarkWatched('456', req)).resolves.not.toThrow(); 161 320 }); 162 321 }); 163 322 });
+8 -3
backend/src/movies/movies.controller.ts
··· 22 22 MarkWatchedDto, 23 23 } from './dto/movie.dto'; 24 24 import { AuthGuard } from '../auth/auth.guard'; 25 - import { AuthenticatedRequest } from '../auth/types'; 25 + import type { AuthenticatedRequest } from '../auth/types'; 26 + import type { ATSession } from './movies.service'; 26 27 27 28 @ApiTags('movies') 28 29 @Controller('movies') ··· 67 68 // Write to user's PDS 68 69 const { uri, cid, rkey, record } = await this.moviesService.markWatched( 69 70 user.did, 70 - user.session, 71 + user.session as ATSession, 71 72 body.movieId, 72 73 ); 73 74 ··· 106 107 const user = req.user; 107 108 108 109 // Delete from user's PDS 109 - await this.moviesService.unmarkWatched(user.did, user.session, movieId); 110 + await this.moviesService.unmarkWatched( 111 + user.did, 112 + user.session as ATSession, 113 + movieId, 114 + ); 110 115 111 116 // Optimistic update: remove from local DB so user sees their changes immediately 112 117 // If this fails, the firehose ingester will catch it later
+204 -6
backend/src/movies/movies.service.spec.ts
··· 6 6 PrismaService: jest.fn(), 7 7 })); 8 8 9 + // Mock @atproto/api Agent 10 + const mockPutRecord = jest.fn(); 11 + const mockDeleteRecord = jest.fn(); 12 + jest.mock('@atproto/api', () => ({ 13 + Agent: jest.fn().mockImplementation(() => ({ 14 + com: { 15 + atproto: { 16 + repo: { 17 + putRecord: mockPutRecord, 18 + deleteRecord: mockDeleteRecord, 19 + }, 20 + }, 21 + }, 22 + })), 23 + })); 24 + 9 25 import { MoviesService } from './movies.service'; 10 26 import { PrismaService } from '../prisma/prisma.service'; 11 27 ··· 19 35 const mockPrismaService = { 20 36 trackedMovie: { 21 37 findMany: jest.fn(), 38 + upsert: jest.fn(), 39 + deleteMany: jest.fn(), 22 40 }, 23 41 movie: { 24 42 findUnique: jest.fn(), ··· 35 53 36 54 beforeEach(async () => { 37 55 jest.clearAllMocks(); 56 + mockPutRecord.mockReset(); 57 + mockDeleteRecord.mockReset(); 38 58 39 59 const module: TestingModule = await Test.createTestingModule({ 40 60 providers: [ ··· 251 271 const movieData = { 252 272 id: 456, 253 273 title: 'Movie Without Date', 254 - poster_path: null, 255 - backdrop_path: null, 256 - release_date: null, 274 + poster_path: undefined, 275 + backdrop_path: undefined, 276 + release_date: undefined, 257 277 overview: 'No release date', 258 278 }; 259 279 mockPrismaService.movie.upsert.mockResolvedValue({ ··· 285 305 const movieData = { 286 306 id: 789, 287 307 title: 'Movie With Empty Date', 288 - poster_path: null, 289 - backdrop_path: null, 308 + poster_path: undefined, 309 + backdrop_path: undefined, 290 310 release_date: '', 291 - overview: null, 311 + overview: undefined, 292 312 }; 293 313 mockPrismaService.movie.upsert.mockResolvedValue({ 294 314 movieId: '789', ··· 308 328 releaseDate: null, 309 329 }), 310 330 }); 331 + }); 332 + }); 333 + 334 + describe('markWatched', () => { 335 + it('should create AT Protocol record and return record info', async () => { 336 + const mockSession = { did: 'did:plc:abc123' }; 337 + const mockPutRecordResponse = { 338 + data: { 339 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 340 + cid: 'cid123', 341 + }, 342 + }; 343 + 344 + mockPutRecord.mockResolvedValue(mockPutRecordResponse); 345 + 346 + const result = await service.markWatched( 347 + 'did:plc:abc123', 348 + mockSession, 349 + '123', 350 + ); 351 + 352 + expect(mockPutRecord).toHaveBeenCalledWith({ 353 + repo: 'did:plc:abc123', 354 + collection: 'app.opnshelf.movie', 355 + rkey: 'movie-123', 356 + record: expect.objectContaining({ 357 + $type: 'app.opnshelf.movie', 358 + movieId: '123', 359 + source: 'tmdb', 360 + }), 361 + validate: false, 362 + }); 363 + expect(result).toMatchObject({ 364 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 365 + cid: 'cid123', 366 + rkey: 'movie-123', 367 + record: expect.objectContaining({ 368 + $type: 'app.opnshelf.movie', 369 + movieId: '123', 370 + source: 'tmdb', 371 + }), 372 + }); 373 + }); 374 + }); 375 + 376 + describe('unmarkWatched', () => { 377 + it('should delete AT Protocol record', async () => { 378 + const mockSession = { did: 'did:plc:abc123' }; 379 + 380 + mockDeleteRecord.mockResolvedValue({}); 381 + 382 + const result = await service.unmarkWatched( 383 + 'did:plc:abc123', 384 + mockSession, 385 + '123', 386 + ); 387 + 388 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 389 + repo: 'did:plc:abc123', 390 + collection: 'app.opnshelf.movie', 391 + rkey: 'movie-123', 392 + }); 393 + expect(result).toEqual({ 394 + rkey: 'movie-123', 395 + movieId: '123', 396 + }); 397 + }); 398 + }); 399 + 400 + describe('indexTrackedMovie', () => { 401 + it('should upsert tracked movie with movie details', async () => { 402 + const mockMovieDetails = { 403 + id: 123, 404 + title: 'Test Movie', 405 + poster_path: '/poster.jpg', 406 + backdrop_path: '/backdrop.jpg', 407 + release_date: '2024-01-01', 408 + overview: 'A test movie', 409 + }; 410 + const mockUpsertedMovie = { 411 + movieId: '123', 412 + title: 'Test Movie', 413 + posterPath: '/poster.jpg', 414 + }; 415 + const mockTrackedMovie = { 416 + id: 'tracked-1', 417 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 418 + rkey: 'movie-123', 419 + cid: 'cid123', 420 + userDid: 'did:plc:abc123', 421 + movieId: '123', 422 + status: 'watched', 423 + watchedDate: new Date('2024-01-15'), 424 + movie: mockUpsertedMovie, 425 + }; 426 + 427 + mockFetch.mockResolvedValue({ 428 + ok: true, 429 + json: () => Promise.resolve(mockMovieDetails), 430 + }); 431 + mockPrismaService.movie.upsert.mockResolvedValue(mockUpsertedMovie); 432 + mockPrismaService.trackedMovie.upsert.mockResolvedValue(mockTrackedMovie); 433 + 434 + const result = await service.indexTrackedMovie( 435 + 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 436 + 'cid123', 437 + 'movie-123', 438 + 'did:plc:abc123', 439 + '123', 440 + '2024-01-15T10:00:00Z', 441 + ); 442 + 443 + expect(mockFetch).toHaveBeenCalledWith( 444 + expect.stringContaining('/movie/123?api_key=test-api-key'), 445 + ); 446 + expect(mockPrismaService.movie.upsert).toHaveBeenCalled(); 447 + expect(mockPrismaService.trackedMovie.upsert).toHaveBeenCalledWith({ 448 + where: { uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-123' }, 449 + create: expect.objectContaining({ 450 + uri: 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 451 + rkey: 'movie-123', 452 + cid: 'cid123', 453 + userDid: 'did:plc:abc123', 454 + movieId: '123', 455 + status: 'watched', 456 + }), 457 + update: expect.objectContaining({ 458 + cid: 'cid123', 459 + status: 'watched', 460 + }), 461 + include: { movie: true }, 462 + }); 463 + expect(result).toEqual(mockTrackedMovie); 464 + }); 465 + 466 + it('should throw error when TMDB API fails during indexing', async () => { 467 + mockFetch.mockResolvedValue({ 468 + ok: false, 469 + status: 404, 470 + }); 471 + 472 + await expect( 473 + service.indexTrackedMovie( 474 + 'at://did:plc:abc123/app.opnshelf.movie/movie-123', 475 + 'cid123', 476 + 'movie-123', 477 + 'did:plc:abc123', 478 + '123', 479 + '2024-01-15T10:00:00Z', 480 + ), 481 + ).rejects.toThrow('Movie not found'); 482 + }); 483 + }); 484 + 485 + describe('removeTrackedMovie', () => { 486 + it('should delete tracked movie records', async () => { 487 + mockPrismaService.trackedMovie.deleteMany.mockResolvedValue({ 488 + count: 1, 489 + } as any); 490 + 491 + await service.removeTrackedMovie('did:plc:abc123', '123'); 492 + 493 + expect(mockPrismaService.trackedMovie.deleteMany).toHaveBeenCalledWith({ 494 + where: { 495 + userDid: 'did:plc:abc123', 496 + movieId: '123', 497 + }, 498 + }); 499 + }); 500 + 501 + it('should handle when no records exist to delete', async () => { 502 + mockPrismaService.trackedMovie.deleteMany.mockResolvedValue({ 503 + count: 0, 504 + } as any); 505 + 506 + await expect( 507 + service.removeTrackedMovie('did:plc:abc123', '999'), 508 + ).resolves.not.toThrow(); 311 509 }); 312 510 }); 313 511 });
+3 -3
backend/src/movies/movies.service.ts
··· 5 5 6 6 const COLLECTION = 'app.opnshelf.movie'; 7 7 8 - interface TMDBMovie { 8 + export interface TMDBMovie { 9 9 id: number; 10 10 title: string; 11 11 poster_path?: string; ··· 14 14 overview?: string; 15 15 } 16 16 17 - interface TMDBSearchResponse { 17 + export interface TMDBSearchResponse { 18 18 page: number; 19 19 results: TMDBMovie[]; 20 20 total_results: number; 21 21 total_pages: number; 22 22 } 23 23 24 - interface ATSession { 24 + export interface ATSession { 25 25 did: string; 26 26 } 27 27