Kaynağa Gözat

Fix Chokidar extension filtering and add comprehensive tests

- Add getDatasetExts method to DatasetsService for retrieving extension arrays
- Fix shouldWatchFile to allow directory traversal while filtering files by extensions
- Add watcher.service.spec.ts with tests for extension filtering logic
- Ensure .nfo and other unwanted files are properly ignored by Chokidar
Timothy Pomeroy 4 hafta önce
ebeveyn
işleme
25f442f89a

+ 12 - 0
apps/service/src/datasets.service.ts

@@ -107,4 +107,16 @@ export class DatasetsService {
       return {};
     }
   }
+
+  /**
+   * Returns the exts array for a specific dataset
+   */
+  getDatasetExts(datasetName: string): string[] | null {
+    const config = this.getDatasetConfig();
+    const datasetConfig = config[datasetName];
+    if (!datasetConfig || !datasetConfig.exts) {
+      return null;
+    }
+    return Array.isArray(datasetConfig.exts) ? datasetConfig.exts : null;
+  }
 }

+ 320 - 0
apps/service/src/watcher.service.spec.ts

@@ -0,0 +1,320 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import chokidar from 'chokidar';
+import { DatasetsService } from './datasets.service';
+import { DbService } from './db.service';
+import { EventsGateway } from './events.gateway';
+import { TaskQueueService } from './task-queue.service';
+import { WatcherService } from './watcher.service';
+
+// Mock chokidar
+jest.mock('chokidar', () => ({
+  watch: jest.fn(),
+}));
+
+// Mock fs
+jest.mock('fs', () => ({
+  statSync: jest.fn((path: string) => ({
+    isDirectory: () => path.endsWith('subdir') || !path.includes('.'),
+  })),
+}));
+
+// Mock Worker
+jest.mock('worker_threads', () => ({
+  Worker: jest.fn().mockImplementation(() => ({
+    on: jest.fn(),
+    postMessage: jest.fn(),
+  })),
+}));
+
+// Mock DatasetsService
+jest.mock('./datasets.service', () => ({
+  DatasetsService: jest.fn().mockImplementation(() => ({
+    getEnabledDatasetPaths: jest.fn(),
+    getDatasetConfig: jest.fn(),
+    getDatasetExts: jest.fn(),
+  })),
+}));
+
+// Mock DbService
+jest.mock('./db.service', () => ({
+  DbService: jest.fn().mockImplementation(() => ({})),
+}));
+
+// Mock EventsGateway
+jest.mock('./events.gateway', () => ({
+  EventsGateway: jest.fn().mockImplementation(() => ({
+    emitFileUpdate: jest.fn(),
+    emitWatcherUpdate: jest.fn(),
+  })),
+}));
+
+// Mock TaskQueueService
+jest.mock('./task-queue.service', () => ({
+  TaskQueueService: jest.fn().mockImplementation(() => ({})),
+}));
+
+describe('WatcherService', () => {
+  let service: WatcherService;
+  let datasetsService: jest.Mocked<DatasetsService>;
+  let dbService: jest.Mocked<DbService>;
+  let eventsGateway: jest.Mocked<EventsGateway>;
+  let taskQueueService: jest.Mocked<TaskQueueService>;
+
+  beforeEach(async () => {
+    const mockDatasetsService = {
+      getEnabledDatasetPaths: jest.fn(),
+      getDatasetConfig: jest.fn(),
+      getDatasetExts: jest.fn(),
+    };
+
+    const mockDbService = {};
+    const mockEventsGateway = {
+      emitFileUpdate: jest.fn(),
+      emitWatcherUpdate: jest.fn(),
+    };
+    const mockTaskQueueService = {};
+
+    const module: TestingModule = await Test.createTestingModule({
+      providers: [
+        WatcherService,
+        {
+          provide: DatasetsService,
+          useValue: mockDatasetsService,
+        },
+        {
+          provide: DbService,
+          useValue: mockDbService,
+        },
+        {
+          provide: EventsGateway,
+          useValue: mockEventsGateway,
+        },
+        {
+          provide: TaskQueueService,
+          useValue: mockTaskQueueService,
+        },
+      ],
+    }).compile();
+
+    service = module.get<WatcherService>(WatcherService);
+    datasetsService = module.get(DatasetsService);
+    dbService = module.get(DbService);
+    eventsGateway = module.get(EventsGateway);
+    taskQueueService = module.get(TaskQueueService);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('getDatasetExts', () => {
+    it('should return exts array for a dataset with exts configured', () => {
+      const mockConfig = {
+        movies: {
+          enabled: true,
+          exts: ['.mkv', '.mp4', '.avi'],
+          '/path/to/movies': {},
+        },
+      };
+
+      datasetsService.getDatasetConfig.mockReturnValue(mockConfig);
+      datasetsService.getDatasetExts.mockReturnValue(['.mkv', '.mp4', '.avi']);
+
+      const exts = datasetsService.getDatasetExts('movies');
+      expect(exts).toEqual(['.mkv', '.mp4', '.avi']);
+    });
+
+    it('should return null for a dataset without exts configured', () => {
+      const mockConfig = {
+        movies: {
+          enabled: true,
+          '/path/to/movies': {},
+        },
+      };
+
+      datasetsService.getDatasetConfig.mockReturnValue(mockConfig);
+      datasetsService.getDatasetExts.mockReturnValue(null);
+
+      const exts = datasetsService.getDatasetExts('movies');
+      expect(exts).toBeNull();
+    });
+  });
+
+  describe('Chokidar options setup', () => {
+    it('should set up ignored function to filter files by extensions', () => {
+      const mockConfig = {
+        movies: {
+          enabled: true,
+          exts: ['.mkv', '.mp4'],
+          '/path/to/movies': {},
+        },
+      };
+
+      datasetsService.getEnabledDatasetPaths.mockReturnValue([
+        '/path/to/movies',
+      ]);
+      datasetsService.getDatasetConfig.mockReturnValue(mockConfig);
+
+      // Mock the getDatasetFromPath method to return 'movies' for our test path
+      jest
+        .spyOn(service as any, 'getDatasetFromPath')
+        .mockReturnValue('movies');
+
+      const mockWatcher = {
+        on: jest.fn().mockReturnThis(),
+      };
+      (chokidar.watch as jest.Mock).mockReturnValue(mockWatcher);
+
+      service.start();
+
+      // Verify chokidar.watch was called
+      expect(chokidar.watch).toHaveBeenCalledWith(
+        ['/path/to/movies'],
+        expect.objectContaining({
+          ignored: expect.any(Function),
+        }),
+      );
+
+      // Get the ignored function from the call
+      const callArgs = (chokidar.watch as jest.Mock).mock.calls[0];
+      const options = callArgs[1];
+      const ignoredFunction = options.ignored;
+
+      // Test the ignored function
+      expect(ignoredFunction('/path/to/movies/movie.mkv')).toBe(false); // Should watch
+      expect(ignoredFunction('/path/to/movies/movie.mp4')).toBe(false); // Should watch
+      expect(ignoredFunction('/path/to/movies/movie.nfo')).toBe(true); // Should ignore
+      expect(ignoredFunction('/path/to/movies/movie.txt')).toBe(true); // Should ignore
+      expect(ignoredFunction('/path/to/movies/subdir')).toBe(false); // Should not ignore directories
+    });
+
+    it('should watch all files when no exts are configured', () => {
+      const mockConfig = {
+        movies: {
+          enabled: true,
+          '/path/to/movies': {},
+        },
+      };
+
+      datasetsService.getEnabledDatasetPaths.mockReturnValue([
+        '/path/to/movies',
+      ]);
+      datasetsService.getDatasetConfig.mockReturnValue(mockConfig);
+
+      // Mock the getDatasetFromPath method to return 'movies' for our test path
+      jest
+        .spyOn(service as any, 'getDatasetFromPath')
+        .mockReturnValue('movies');
+
+      const mockWatcher = {
+        on: jest.fn().mockReturnThis(),
+      };
+      (chokidar.watch as jest.Mock).mockReturnValue(mockWatcher);
+
+      service.start();
+
+      // Get the ignored function from the call
+      const callArgs = (chokidar.watch as jest.Mock).mock.calls[0];
+      const options = callArgs[1];
+      const ignoredFunction = options.ignored;
+
+      // Test the ignored function - should watch all files when no exts configured
+      expect(ignoredFunction('/path/to/movies/movie.mkv')).toBe(false);
+      expect(ignoredFunction('/path/to/movies/movie.nfo')).toBe(false);
+      expect(ignoredFunction('/path/to/movies/movie.txt')).toBe(false);
+    });
+
+    it('should ignore files that do not belong to any dataset', () => {
+      const mockConfig = {
+        movies: {
+          enabled: true,
+          exts: ['.mkv', '.mp4'],
+          '/path/to/movies': {},
+        },
+      };
+
+      datasetsService.getEnabledDatasetPaths.mockReturnValue([
+        '/path/to/movies',
+      ]);
+      datasetsService.getDatasetConfig.mockReturnValue(mockConfig);
+
+      // Mock the getDatasetFromPath method to return null for files outside datasets
+      jest.spyOn(service as any, 'getDatasetFromPath').mockReturnValue(null);
+
+      const mockWatcher = {
+        on: jest.fn().mockReturnThis(),
+      };
+      (chokidar.watch as jest.Mock).mockReturnValue(mockWatcher);
+
+      service.start();
+
+      // Get the ignored function from the call
+      const callArgs = (chokidar.watch as jest.Mock).mock.calls[0];
+      const options = callArgs[1];
+      const ignoredFunction = options.ignored;
+
+      // Test the ignored function - should ignore files not in any dataset
+      expect(ignoredFunction('/some/other/path/movie.mkv')).toBe(true);
+    });
+  });
+
+  describe('handleFileAdded', () => {
+    it('should skip processing files with extensions not in dataset exts', () => {
+      const mockConfig = {
+        movies: {
+          enabled: true,
+          exts: ['.mkv', '.mp4'],
+          '/path/to/movies': {},
+        },
+      };
+
+      datasetsService.getDatasetConfig.mockReturnValue(mockConfig);
+
+      // Mock the getDatasetFromPath method
+      jest
+        .spyOn(service as any, 'getDatasetFromPath')
+        .mockReturnValue('movies');
+
+      // Spy on logger to check if debug message is logged
+      const loggerSpy = jest.spyOn(service['logger'], 'debug');
+
+      // Call handleFileAdded with a .nfo file
+      (service as any).handleFileAdded('/path/to/movies/movie.nfo');
+
+      // Should log debug message and return early
+      expect(loggerSpy).toHaveBeenCalledWith(
+        'Skipping file /path/to/movies/movie.nfo - extension not in dataset exts array',
+      );
+    });
+
+    it('should process files with extensions in dataset exts', () => {
+      const mockConfig = {
+        movies: {
+          enabled: true,
+          exts: ['.mkv', '.mp4'],
+          '/path/to/movies': {},
+        },
+      };
+
+      datasetsService.getDatasetConfig.mockReturnValue(mockConfig);
+
+      // Mock the getDatasetFromPath method
+      jest
+        .spyOn(service as any, 'getDatasetFromPath')
+        .mockReturnValue('movies');
+
+      // Mock the validation worker
+      const postMessageSpy = jest.fn();
+      (service as any).validationWorker.postMessage = postMessageSpy;
+
+      // Call handleFileAdded with a .mkv file
+      (service as any).handleFileAdded('/path/to/movies/movie.mkv');
+
+      // Should call validation worker
+      expect(postMessageSpy).toHaveBeenCalledWith({
+        type: 'validate_file',
+        file: '/path/to/movies/movie.mkv',
+      });
+    });
+  });
+});

+ 9 - 0
apps/service/src/watcher.service.ts

@@ -64,6 +64,15 @@ export class WatcherService {
 
     // Create a function to determine if a file should be watched based on dataset extensions
     const shouldWatchFile = (filePath: string): boolean => {
+      // Always allow directories to be traversed
+      try {
+        if (fs.statSync(filePath).isDirectory()) {
+          return true;
+        }
+      } catch {
+        // If we can't stat the file, assume it's a file and continue with filtering
+      }
+
       // Get the dataset for this file path
       const dataset = this.getDatasetFromPath(filePath);
       if (!dataset) {