Skip to main content
Glama

mcp-youtube

by kirbah
getChannelTopVideos.test.ts33.9 kB
import { YoutubeService } from "../../youtube.service"; import { CacheService } from "../../cache.service"; // Import CacheService import { google } from "googleapis"; import { parseYouTubeNumber } from "../../../utils/numberParser"; import { calculateLikeToViewRatio, calculateCommentToViewRatio, } from "../../../utils/engagementCalculator"; import { truncateDescription } from "../../../utils/textUtils"; jest.mock("../../cache.service"); // Mock CacheService jest.mock("../../../utils/numberParser"); jest.mock("../../../utils/engagementCalculator"); jest.mock("../../../utils/textUtils"); jest.mock("googleapis", () => ({ google: { youtube: jest.fn(() => ({ search: { list: jest.fn(), }, videos: { list: jest.fn(), }, channels: { list: jest.fn(), }, videoCategories: { list: jest.fn(), }, })), }, })); const mockYoutube = google.youtube as jest.Mock; // Helper functions for generating mock API responses // NOTE: youtube_v3 is not directly available here, so we'll use `any` or define minimal interfaces // For simplicity, using `any` for Schema$SearchListResponse and Schema$VideoListResponse const generateMockSearchResults = ( count: number ): any /* youtube_v3.Schema$SearchListResponse */ => ({ data: { items: Array.from({ length: count }, (_, i) => ({ id: { videoId: `video_${i}` }, })), nextPageToken: undefined, // Assuming a single page of search results for these tests }, }); const generateMockVideoDetails = ( ids: string[] ): any /* youtube_v3.Schema$VideoListResponse */ => ({ data: { items: ids.map((id) => ({ id, snippet: { title: `Title for ${id}`, publishedAt: "2023-01-01T00:00:00Z", }, statistics: { viewCount: "1000", likeCount: "100", commentCount: "10" }, contentDetails: { duration: "PT5M" }, })), }, }); describe("YoutubeService.getChannelTopVideos", () => { let videoManagement: YoutubeService; let mockSearchList: jest.Mock; let mockVideosList: jest.Mock; let mockCacheService: jest.Mocked<CacheService>; // Declare mockCacheService beforeEach(() => { // Reset mocks for each test mockSearchList = jest.fn(); mockVideosList = jest.fn(); mockYoutube.mockImplementation(() => ({ search: { list: mockSearchList, }, videos: { list: mockVideosList, }, channels: { // Added to avoid undefined errors if other methods use it list: jest.fn(), }, videoCategories: { // Added to avoid undefined errors if other methods use it list: jest.fn(), }, })); // Mock CacheService and its methods mockCacheService = new CacheService({} as any); mockCacheService.createOperationKey.mockImplementation( (operationName, options) => `${operationName}-${JSON.stringify(options || {})}` ); mockCacheService.getOrSet.mockImplementation( async (key, operation, _ttl, _collection) => operation() ); videoManagement = new YoutubeService(mockCacheService); // Pass mockCacheService (parseYouTubeNumber as jest.Mock).mockImplementation((val) => parseInt(val || "0") ); (calculateLikeToViewRatio as jest.Mock).mockReturnValue(0.1); (calculateCommentToViewRatio as jest.Mock).mockReturnValue(0.01); (truncateDescription as jest.Mock).mockImplementation( (desc) => desc || null ); }); afterEach(() => { jest.clearAllMocks(); }); // Tests will go here it("should return top videos when API call is successful", async () => { const mockChannelId = "UC_channel_id"; const mockVideos = [ { id: "video1", title: "Video 1" }, { id: "video2", title: "Video 2" }, ]; const mockApiResponse = { data: { items: mockVideos.map((video) => ({ id: { videoId: video.id }, snippet: { title: video.title }, })), }, }; mockSearchList.mockResolvedValue(mockApiResponse); // Mock videos.list to return details for the video IDs found by search mockVideosList.mockImplementation(async (params) => { const ids = params.id as string[]; return { data: { items: ids.map((id) => { const originalVideo = mockVideos.find((v) => v.id === id); return { id: id, snippet: { title: originalVideo?.title || `Title for ${id}` }, statistics: { viewCount: "100", likeCount: "10", commentCount: "1", }, // Dummy stats contentDetails: { duration: "PT1M" }, // Dummy duration }; }), }, }; }); const result = await videoManagement.getChannelTopVideos({ channelId: mockChannelId, }); expect(mockSearchList).toHaveBeenCalledWith({ part: ["id"], // Verify part is ["id"] channelId: mockChannelId, order: "viewCount", type: ["video"], maxResults: 10, // Default maxResults pageToken: undefined, // Initial pageToken }); expect(result).toEqual( mockVideos.map((video) => ({ id: video.id, title: video.title, publishedAt: undefined, // snippet.publishedAt is not in the mock for videos.list in this test duration: "PT1M", // From the mockVideosList implementation in this test viewCount: 100, // From the mockVideosList implementation likeCount: 10, // From the mockVideosList implementation commentCount: 1, // From the mockVideosList implementation likeToViewRatio: 0.1, // Default mock from beforeEach commentToViewRatio: 0.01, // Default mock from beforeEach categoryId: null, // Default null for undefined categoryId defaultLanguage: null, // Default null for undefined defaultLanguage })) ); }); it("should call youtube.search.list with correct parameters when maxResults is provided", async () => { const mockChannelId = "UC_channel_id"; const mockMaxResults = 5; const mockVideos = [ { id: "video1", title: "Video 1" }, { id: "video2", title: "Video 2" }, ]; const mockApiResponse = { data: { items: mockVideos.map((video) => ({ id: { videoId: video.id }, snippet: { title: video.title }, })), }, }; mockSearchList.mockResolvedValue(mockApiResponse); // Mock videos.list to return details for the video IDs found by search mockVideosList.mockImplementation(async (params) => { const ids = params.id as string[]; return { data: { items: ids.map((id) => { const originalVideo = mockVideos.find((v) => v.id === id); return { id: id, snippet: { title: originalVideo?.title || `Title for ${id}` }, statistics: { viewCount: "200", likeCount: "20", commentCount: "2", }, // Dummy stats contentDetails: { duration: "PT2M" }, // Dummy duration }; }), }, }; }); await videoManagement.getChannelTopVideos({ channelId: mockChannelId, maxResults: mockMaxResults, }); expect(mockSearchList).toHaveBeenCalledWith({ part: ["id"], channelId: mockChannelId, order: "viewCount", type: ["video"], maxResults: mockMaxResults, // Verify provided maxResults pageToken: undefined, }); }); it("should throw an error when API call fails", async () => { const mockChannelId = "UC_channel_id"; const errorMessage = "API error"; mockSearchList.mockRejectedValue(new Error(errorMessage)); await expect( videoManagement.getChannelTopVideos({ channelId: mockChannelId }) ).rejects.toThrow( `YouTube API call for getChannelTopVideos failed for channelId: ${mockChannelId}` ); expect(mockSearchList).toHaveBeenCalledWith({ part: ["id"], channelId: mockChannelId, order: "viewCount", type: ["video"], maxResults: 10, pageToken: undefined, }); }); it("should throw 'No videos found.' error when youtube.search.list returns no items", async () => { const mockChannelId = "UC_channel_id_no_videos"; mockSearchList.mockResolvedValue({ data: { items: [], // No items returned }, }); await expect( videoManagement.getChannelTopVideos({ channelId: mockChannelId }) ).rejects.toThrow( `YouTube API call for getChannelTopVideos failed for channelId: ${mockChannelId}` ); expect(mockSearchList).toHaveBeenCalledWith({ part: ["id"], channelId: mockChannelId, order: "viewCount", type: ["video"], maxResults: 10, // Default maxResults pageToken: undefined, }); }); it("should handle pagination correctly when maxResults is greater than MAX_RESULTS_PER_PAGE", async () => { const mockChannelId = "UC_channel_id_pagination"; const requestedMaxResults = 75; // Greater than MAX_RESULTS_PER_PAGE (50) const MAX_RESULTS_PER_PAGE = 50; // Generate unique video IDs for search results const searchResultItemsPage1 = Array.from( { length: MAX_RESULTS_PER_PAGE }, (_, i) => ({ id: { videoId: `video_id_page1_${i}` }, }) ); const searchResultItemsPage2 = Array.from( { length: requestedMaxResults - MAX_RESULTS_PER_PAGE }, (_, i) => ({ id: { videoId: `video_id_page2_${i}` }, }) ); const nextPageToken = "nextPageToken123"; // Mock search.list responses mockSearchList .mockResolvedValueOnce({ // First call data: { items: searchResultItemsPage1, nextPageToken: nextPageToken, }, }) .mockResolvedValueOnce({ // Second call data: { items: searchResultItemsPage2, nextPageToken: null, // No more pages }, }); // Combine all video IDs from search results const allVideoIds = [ ...searchResultItemsPage1.map((item) => item.id.videoId), ...searchResultItemsPage2.map((item) => item.id.videoId), ]; // Mock videos.list response const mockVideoDetailsItems = allVideoIds.map((id) => ({ id: id, snippet: { title: `Title for ${id}`, publishedAt: "2023-01-01T00:00:00Z", }, statistics: { viewCount: "100", likeCount: "10", commentCount: "1" }, contentDetails: { duration: "PT1M30S" }, })); // mockVideosList.mockResolvedValue({ // Old simple mock // data: { items: mockVideoDetailsItems }, // }); mockVideosList.mockImplementation(async (params) => { const ids = params.id as string[]; const requestedDetails = mockVideoDetailsItems.filter((detailItem) => ids.includes(detailItem.id) ); return { data: { items: requestedDetails } }; }); const result = await videoManagement.getChannelTopVideos({ channelId: mockChannelId, maxResults: requestedMaxResults, }); // Assertions for search.list calls expect(mockSearchList).toHaveBeenCalledTimes(2); expect(mockSearchList).toHaveBeenNthCalledWith(1, { part: ["id"], channelId: mockChannelId, order: "viewCount", type: ["video"], maxResults: MAX_RESULTS_PER_PAGE, // First call should use MAX_RESULTS_PER_PAGE pageToken: undefined, }); expect(mockSearchList).toHaveBeenNthCalledWith(2, { part: ["id"], channelId: mockChannelId, order: "viewCount", type: ["video"], maxResults: requestedMaxResults - MAX_RESULTS_PER_PAGE, // Remaining results for the second call pageToken: nextPageToken, // Should use the nextPageToken from the first call }); // Assertions for videos.list call expect(mockVideosList).toHaveBeenCalledTimes(2); // For 75 videos, 50 + 25 // Check if videos.list was called with chunks of 50, or if the implementation fetches all at once after search // Based on current understanding of getChannelTopVideos, it collects all IDs then fetches video details. // The toHaveBeenNthCalledWith assertions are more precise for batched calls. // Removing the overly broad toHaveBeenCalledWith that expects all IDs at once. // expect(mockVideosList).toHaveBeenCalledWith({ // part: ['snippet', 'statistics', 'contentDetails'], // id: expect.arrayContaining(allVideoIds), // maxResults: allVideoIds.length, // It should fetch details for all found IDs // }); // Assertions for the result expect(result).toHaveLength(requestedMaxResults); allVideoIds.forEach((videoId) => { const video = result.find((v) => v.id === videoId); expect(video).toBeDefined(); expect(video).toMatchObject({ id: videoId, title: `Title for ${videoId}`, // Add other relevant fields if necessary, considering mocked helper functions }); }); }); it("should respect ABSOLUTE_MAX_RESULTS when maxResults is very large", async () => { const mockChannelId = "UC_channel_id_absolute_max"; const requestedMaxResults = 600; // Greater than ABSOLUTE_MAX_RESULTS (500) const MAX_RESULTS_PER_PAGE = 50; const ABSOLUTE_MAX_RESULTS = 500; // Defined in VideoManagement class const generateSearchPage = ( pageNumber: number, itemsOnPage: number, hasNextPage: boolean ) => { return { data: { items: Array.from({ length: itemsOnPage }, (_, i) => ({ id: { videoId: `video_id_abs_page${pageNumber}_${i}` }, })), nextPageToken: hasNextPage ? `nextPageToken_abs_${pageNumber}` : null, }, }; }; // Mock search.list to return multiple pages // It should make 10 calls for 500 results if maxResults is 50 each time. for (let i = 0; i < ABSOLUTE_MAX_RESULTS / MAX_RESULTS_PER_PAGE; i++) { mockSearchList.mockResolvedValueOnce( generateSearchPage( i + 1, MAX_RESULTS_PER_PAGE, i < ABSOLUTE_MAX_RESULTS / MAX_RESULTS_PER_PAGE - 1 ) ); } // This last call might not be made if logic correctly caps at ABSOLUTE_MAX_RESULTS before fetching this page. // Add one more potential page if logic were to try and fetch up to requestedMaxResults mockSearchList.mockResolvedValueOnce( generateSearchPage(11, MAX_RESULTS_PER_PAGE, false) ); const allVideoIdsSearched = []; for (let i = 0; i < ABSOLUTE_MAX_RESULTS / MAX_RESULTS_PER_PAGE; i++) { for (let j = 0; j < MAX_RESULTS_PER_PAGE; j++) { allVideoIdsSearched.push(`video_id_abs_page${i + 1}_${j}`); } } // Mock videos.list response for up to ABSOLUTE_MAX_RESULTS items const mockVideoDetailsItems = allVideoIdsSearched .slice(0, ABSOLUTE_MAX_RESULTS) .map((id) => ({ id: id, snippet: { title: `Title for ${id}`, publishedAt: "2023-01-01T00:00:00Z", }, statistics: { viewCount: "100", likeCount: "10", commentCount: "1" }, contentDetails: { duration: "PT1M30S" }, })); // mockVideosList.mockResolvedValue({ // Old simple mock // data: { items: mockVideoDetailsItems }, // }); mockVideosList.mockImplementation(async (params) => { const ids = params.id as string[]; const requestedDetails = mockVideoDetailsItems.filter((detailItem) => ids.includes(detailItem.id) ); return { data: { items: requestedDetails } }; }); const result = await videoManagement.getChannelTopVideos({ channelId: mockChannelId, maxResults: requestedMaxResults, }); // Assertions for search.list calls // It should have made 10 calls (500 / 50 = 10) const expectedSearchCalls = ABSOLUTE_MAX_RESULTS / MAX_RESULTS_PER_PAGE; expect(mockSearchList).toHaveBeenCalledTimes(expectedSearchCalls); for (let i = 0; i < expectedSearchCalls; i++) { expect(mockSearchList).toHaveBeenNthCalledWith(i + 1, { part: ["id"], channelId: mockChannelId, order: "viewCount", type: ["video"], maxResults: MAX_RESULTS_PER_PAGE, pageToken: i === 0 ? undefined : `nextPageToken_abs_${i}`, }); } // Assertions for videos.list call expect(mockVideosList).toHaveBeenCalledTimes(10); // 500 videos / 50 per batch = 10 calls // The toHaveBeenNthCalledWith assertions would be more appropriate here if we needed to check each call, // but for this test, ensuring the total count and final result is sufficient. // Removing the broad toHaveBeenCalledWith as it's problematic with batching. // expect(mockVideosList).toHaveBeenCalledWith({ // part: ['snippet', 'statistics', 'contentDetails'], // id: expect.arrayContaining(allVideoIdsSearched.slice(0, ABSOLUTE_MAX_RESULTS)), // maxResults: ABSOLUTE_MAX_RESULTS, // }); // Assertions for the result expect(result).toHaveLength(ABSOLUTE_MAX_RESULTS); allVideoIdsSearched.slice(0, ABSOLUTE_MAX_RESULTS).forEach((videoId) => { const video = result.find((v) => v.id === videoId); expect(video).toBeDefined(); expect(video).toMatchObject({ id: videoId, title: `Title for ${videoId}`, }); }); }); it("should correctly extract video IDs from search results, skipping items without videoId", async () => { const mockChannelId = "UC_channel_id_extraction_test"; const mockSearchResults = { data: { items: [ { id: { videoId: "video1" } }, { id: { videoId: "video2" } }, { id: {} }, // Missing videoId, should be filtered by .filter(id => id !== undefined) { snippet: { title: "Just a snippet" } }, // Missing id, should be filtered by .map(item => item.id?.videoId) // null and undefined items removed { id: { videoId: "video3" } }, { id: { channelId: "some_channel_id_instead_of_video" } }, // Item that is not a video, should be filtered ], }, }; mockSearchList.mockResolvedValue(mockSearchResults); const validVideoIds = ["video1", "video2", "video3"]; // These are the only ones expected to make it through const mockVideoDetails = validVideoIds.map((id) => ({ id, snippet: { title: `Title for ${id}`, publishedAt: "2023-01-01T00:00:00Z", }, statistics: { viewCount: "100", likeCount: "10", commentCount: "1" }, contentDetails: { duration: "PT1M" }, })); mockVideosList.mockImplementation(async (params) => { // Ensure params.id is an array before filtering const requestedIds = Array.isArray(params.id) ? params.id : params.id ? [params.id] : []; const itemsToReturn = mockVideoDetails.filter((detail) => requestedIds.includes(detail.id) ); return { data: { items: itemsToReturn } }; }); const result = await videoManagement.getChannelTopVideos({ channelId: mockChannelId, maxResults: 10, }); expect(mockSearchList).toHaveBeenCalledWith({ part: ["id"], channelId: mockChannelId, order: "viewCount", type: ["video"], maxResults: 10, pageToken: undefined, }); // Assert that videos.list was called with only the valid IDs expect(mockVideosList).toHaveBeenCalledTimes(1); expect(mockVideosList).toHaveBeenCalledWith({ part: ["snippet", "statistics", "contentDetails"], id: validVideoIds, // Expect only valid IDs // maxResults is not actually passed by the code for videos.list when given IDs }); // Assert that the final result contains videos corresponding to the valid IDs expect(result).toHaveLength(validVideoIds.length); validVideoIds.forEach((validId) => { expect(result.some((video) => video.id === validId)).toBe(true); // Changed video.videoId to video.id const videoInResult = result.find((v) => v.id === validId); // Changed v.videoId to v.id expect(videoInResult).toBeDefined(); expect(videoInResult?.title).toBe(`Title for ${validId}`); // Changed videoTitle to title }); // Also ensure no undefined or problematic items in the result result.forEach((video) => { expect(video).toBeDefined(); expect(video.id).toBeDefined(); // Changed video.videoId to video.id }); }); it("should call youtube.videos.list in batches if video IDs exceed MAX_RESULTS_PER_PAGE", async () => { const mockChannelId = "UC_channel_id_batch_gt_max"; const totalVideosToFetch = 70; const MAX_RESULTS_PER_PAGE = 50; // From VideoManagement mockSearchList.mockResolvedValue( generateMockSearchResults(totalVideosToFetch) ); const expectedVideoIds = Array.from( { length: totalVideosToFetch }, (_, i) => `video_${i}` ); const firstBatchIds = expectedVideoIds.slice(0, MAX_RESULTS_PER_PAGE); const secondBatchIds = expectedVideoIds.slice(MAX_RESULTS_PER_PAGE); mockVideosList .mockImplementationOnce(async (params) => { expect(params.id).toEqual(firstBatchIds); expect(params.id?.length).toBe(MAX_RESULTS_PER_PAGE); return generateMockVideoDetails(params.id as string[]); }) .mockImplementationOnce(async (params) => { expect(params.id).toEqual(secondBatchIds); expect(params.id?.length).toBe( totalVideosToFetch - MAX_RESULTS_PER_PAGE ); return generateMockVideoDetails(params.id as string[]); }); const result = await videoManagement.getChannelTopVideos({ channelId: mockChannelId, maxResults: totalVideosToFetch, }); expect(mockSearchList).toHaveBeenCalledTimes(1); // Search once expect(mockVideosList).toHaveBeenCalledTimes(2); // Called in two batches // Verify calls to videos.list expect(mockVideosList).toHaveBeenNthCalledWith(1, { part: ["snippet", "statistics", "contentDetails"], id: firstBatchIds, // maxResults: MAX_RESULTS_PER_PAGE, // Not passed by code }); expect(mockVideosList).toHaveBeenNthCalledWith(2, { part: ["snippet", "statistics", "contentDetails"], id: secondBatchIds, // maxResults: secondBatchIds.length, // Not passed by code }); expect(result).toHaveLength(totalVideosToFetch); // Verify some details from the result to ensure data is correctly transformed for (let i = 0; i < totalVideosToFetch; i++) { expect(result[i].id).toBe(`video_${i}`); // Changed from videoId to id expect(result[i].title).toBe(`Title for video_${i}`); // Changed from videoTitle to title } }); it("should call youtube.videos.list once if video IDs count is MAX_RESULTS_PER_PAGE", async () => { const mockChannelId = "UC_channel_id_batch_eq_max"; const totalVideosToFetch = 50; // Exactly MAX_RESULTS_PER_PAGE const MAX_RESULTS_PER_PAGE = 50; // From VideoManagement mockSearchList.mockResolvedValue( generateMockSearchResults(totalVideosToFetch) ); const expectedVideoIds = Array.from( { length: totalVideosToFetch }, (_, i) => `video_${i}` ); mockVideosList.mockImplementationOnce(async (params) => { expect(params.id).toEqual(expectedVideoIds); expect(params.id?.length).toBe(MAX_RESULTS_PER_PAGE); return generateMockVideoDetails(params.id as string[]); }); const result = await videoManagement.getChannelTopVideos({ channelId: mockChannelId, maxResults: totalVideosToFetch, }); expect(mockSearchList).toHaveBeenCalledTimes(1); expect(mockVideosList).toHaveBeenCalledTimes(1); // Called only once expect(mockVideosList).toHaveBeenCalledWith({ part: ["snippet", "statistics", "contentDetails"], id: expectedVideoIds, // maxResults: MAX_RESULTS_PER_PAGE, // Not passed by code }); expect(result).toHaveLength(totalVideosToFetch); for (let i = 0; i < totalVideosToFetch; i++) { expect(result[i].id).toBe(`video_${i}`); // Changed from videoId to id expect(result[i].title).toBe(`Title for video_${i}`); // Changed from videoTitle to title } }); it("should correctly transform video details to LeanChannelTopVideo objects", async () => { const mockChannelId = "UC_channel_id_transform_test"; const mockVideoIdsFromSearch = ["video1", "video2", "video3"]; // Refined mocks for helpers for this specific test (parseYouTubeNumber as jest.Mock).mockImplementation((value) => { if (value === null || value === undefined) return 0; return parseInt(value, 10); }); (calculateLikeToViewRatio as jest.Mock).mockImplementation( (viewCount, likeCount) => { if (!viewCount || !likeCount || viewCount === 0) return 0; // Ensure no division by zero return Number((likeCount / viewCount).toFixed(4)); // Example precision } ); (calculateCommentToViewRatio as jest.Mock).mockImplementation( (viewCount, commentCount) => { if (!viewCount || !commentCount || viewCount === 0) return 0; // Ensure no division by zero return Number((commentCount / viewCount).toFixed(4)); // Example precision } ); mockSearchList.mockResolvedValue({ data: { items: mockVideoIdsFromSearch.map((id) => ({ id: { videoId: id } })), }, }); const mockVideoApiItems: any[] /* youtube_v3.Schema$Video[] */ = [ { id: "video1", snippet: { title: "Video Title 1", publishedAt: "2023-01-01T10:00:00Z", }, contentDetails: { duration: "PT1M30S" }, statistics: { viewCount: "1000", likeCount: "150", commentCount: "25" }, }, { id: "video2", snippet: { title: "Second Video", publishedAt: "2023-02-15T12:30:00Z" }, contentDetails: { duration: "PT2M45S" }, statistics: { viewCount: "2500", likeCount: "300", commentCount: "50" }, }, { id: "video3", // Video with potentially missing/null statistic values snippet: { title: "Video With Missing Stats", publishedAt: "2023-03-20T15:00:00Z", }, contentDetails: { duration: "PT3M00S" }, statistics: { viewCount: "500", likeCount: null, commentCount: undefined, }, }, ]; mockVideosList.mockResolvedValue({ data: { items: mockVideoApiItems } }); const result = await videoManagement.getChannelTopVideos({ channelId: mockChannelId, maxResults: mockVideoIdsFromSearch.length, }); expect(result).toHaveLength(mockVideoApiItems.length); mockVideoApiItems.forEach((apiItem) => { const transformedVideo = result.find((v) => v.id === apiItem.id); // Changed v.videoId to v.id expect(transformedVideo).toBeDefined(); const expectedViewCount = parseYouTubeNumber( apiItem.statistics.viewCount ); const expectedLikeCount = parseYouTubeNumber( apiItem.statistics.likeCount ); const expectedCommentCount = parseYouTubeNumber( apiItem.statistics.commentCount ); expect(transformedVideo?.id).toBe(apiItem.id); // Changed from videoId to id expect(transformedVideo?.title).toBe(apiItem.snippet?.title); // Changed from videoTitle to title expect(transformedVideo?.publishedAt).toBe(apiItem.snippet?.publishedAt); expect(transformedVideo?.duration).toBe(apiItem.contentDetails?.duration); expect(transformedVideo?.viewCount).toBe(expectedViewCount); expect(transformedVideo?.likeCount).toBe(expectedLikeCount); expect(transformedVideo?.commentCount).toBe(expectedCommentCount); expect(transformedVideo?.likeToViewRatio).toBe( calculateLikeToViewRatio(expectedViewCount, expectedLikeCount) ); expect(transformedVideo?.commentToViewRatio).toBe( calculateCommentToViewRatio(expectedViewCount, expectedCommentCount) ); }); // Specific checks for video3 with missing stats const video3Transformed = result.find((v) => v.id === "video3"); // Changed v.videoId to v.id expect(video3Transformed?.viewCount).toBe(500); expect(video3Transformed?.likeCount).toBe(0); // Parsed from null expect(video3Transformed?.commentCount).toBe(0); // Parsed from undefined expect(video3Transformed?.likeToViewRatio).toBe(0); // Calculated with likeCount = 0 expect(video3Transformed?.commentToViewRatio).toBe(0); // Calculated with commentCount = 0 }); it("should slice the final results to targetResults if more videos are processed than requested", async () => { const mockChannelId = "UC_channel_id_slicing_test"; const targetResults = 3; // User requests 3 videos // Search returns more IDs than requested initially (e.g., 5) // This situation can happen if initial search fetches a page of 50, but user only wants 3. const mockVideoIdsFromSearch = [ "video1", "video2", "video3", "video4", "video5", ]; mockSearchList.mockResolvedValue({ data: { items: mockVideoIdsFromSearch.map((id) => ({ id: { videoId: id } })), nextPageToken: undefined, // Assume single page for simplicity here }, }); // videos.list is mocked to return details for all 5 IDs fetched by search // In a real scenario, if search returned 5 IDs and targetResults is 3, // videos.list would be called with those 5 IDs (if < MAX_RESULTS_PER_PAGE). mockVideosList.mockResolvedValue({ data: { items: mockVideoIdsFromSearch.map((id) => ({ id, snippet: { title: `Title for ${id}`, publishedAt: "2023-01-01T00:00:00Z", }, contentDetails: { duration: "PT1M" }, statistics: { viewCount: "100", likeCount: "10", commentCount: "1" }, })), }, }); // Restore default mock implementations for helpers if they were changed in a previous test // (This is good practice if tests run in parallel or order is not guaranteed, // though Jest runs them sequentially by default. For clarity, explicitly setting them // to what this test expects or relying on beforeEach is better.) (parseYouTubeNumber as jest.Mock).mockImplementation((val) => parseInt(val || "0") ); (calculateLikeToViewRatio as jest.Mock).mockReturnValue(0.1); (calculateCommentToViewRatio as jest.Mock).mockReturnValue(0.01); const result = await videoManagement.getChannelTopVideos({ channelId: mockChannelId, maxResults: targetResults, }); // Assert that search was called (it might ask for more initially, up to MAX_RESULTS_PER_PAGE) expect(mockSearchList).toHaveBeenCalledTimes(1); // params.maxResults for search will be min(targetResults, MAX_RESULTS_PER_PAGE) if targetResults is small, // or MAX_RESULTS_PER_PAGE if targetResults is large. // For targetResults = 3, search will be called for 3. expect(mockSearchList).toHaveBeenCalledWith( expect.objectContaining({ maxResults: targetResults, }) ); // videos.list would be called with the IDs from search (up to search's maxResults) // In this mock, search returns 5 IDs, and it will ask for details for these 5. // However, the actual implementation of getChannelTopVideos, if it gets 5 IDs from search // but only needs 3, might optimize to only call videos.list for 3 IDs. // Let's assume it fetches details for what search returned (if < MAX_RESULTS_PER_PAGE) // and then slices. The current implementation fetches details for *all* IDs found by search // (up to ABSOLUTE_MAX_RESULTS), then slices. // So, videos.list will be called for 5 video IDs. expect(mockVideosList).toHaveBeenCalledTimes(1); expect(mockVideosList).toHaveBeenCalledWith( expect.objectContaining({ id: mockVideoIdsFromSearch, // It will ask for all 5 // maxResults: mockVideoIdsFromSearch.length // Not passed by code }) ); // Assert that the final result is sliced to targetResults expect(result).toHaveLength(targetResults); expect( result .map((v) => v.id) .filter((id): id is string => id !== null && id !== undefined) ).toEqual( // Filter out null/undefined mockVideoIdsFromSearch.slice(0, targetResults) ); expect(result[0].id).toBe("video1"); expect(result[1].id).toBe("video2"); expect(result[2].id).toBe("video3"); }); it("should throw a formatted error if youtube.videos.list fails", async () => { const mockChannelId = "UC_channel_id_videos_fail"; const mockVideoIdsFromSearch = ["video1", "video2"]; mockSearchList.mockResolvedValue({ data: { items: mockVideoIdsFromSearch.map((id) => ({ id: { videoId: id } })), }, }); const errorMessage = "Failed to fetch video details"; mockVideosList.mockRejectedValue(new Error(errorMessage)); await expect( videoManagement.getChannelTopVideos({ channelId: mockChannelId }) ).rejects.toThrow( `YouTube API call for getChannelTopVideos failed for channelId: ${mockChannelId}` ); // Verify search.list was called expect(mockSearchList).toHaveBeenCalledTimes(1); expect(mockSearchList).toHaveBeenCalledWith( expect.objectContaining({ channelId: mockChannelId, }) ); // Verify videos.list was attempted expect(mockVideosList).toHaveBeenCalledTimes(1); expect(mockVideosList).toHaveBeenCalledWith( expect.objectContaining({ id: mockVideoIdsFromSearch, }) ); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kirbah/mcp-youtube'

If you have feedback or need assistance with the MCP directory API, please join our Discord server