Import Instagram archive to a Bluesky account
9
fork

Configure Feed

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

Complete TODO for splitting monolith of media.test.ts into their specific test files.

- Clean up imports to use the index versus the media file.

+612 -312
+90
src/image/image.test.ts
··· 1 + import { getImageSize } from "./image"; 2 + 3 + // Mock the file system 4 + jest.mock("fs", () => ({ 5 + readFileSync: jest.fn(), 6 + })); 7 + 8 + // Mock sharp 9 + jest.mock("sharp", () => { 10 + return function (filePath: string) { 11 + // Mock different behavior based on file path for testing different scenarios 12 + if (filePath && filePath.includes("missing.jpg")) { 13 + throw new Error("Input file is missing"); 14 + } 15 + 16 + if (filePath && filePath.includes("invalid.jpg")) { 17 + return { 18 + metadata: jest.fn().mockResolvedValue({}), 19 + }; 20 + } 21 + 22 + if (filePath && filePath.includes("landscape.jpg")) { 23 + return { 24 + metadata: jest.fn().mockResolvedValue({ width: 1920, height: 1080 }), 25 + }; 26 + } 27 + 28 + if (filePath && filePath.includes("portrait.jpg")) { 29 + return { 30 + metadata: jest.fn().mockResolvedValue({ width: 1080, height: 1920 }), 31 + }; 32 + } 33 + 34 + // Default square image 35 + return { 36 + metadata: jest.fn().mockResolvedValue({ width: 1080, height: 1080 }), 37 + }; 38 + }; 39 + }); 40 + 41 + // Mock the logger 42 + jest.mock("../logger/logger", () => ({ 43 + logger: { 44 + error: jest.fn(), 45 + warn: jest.fn(), 46 + info: jest.fn(), 47 + debug: jest.fn(), 48 + }, 49 + })); 50 + 51 + describe("getImageSize", () => { 52 + test("should return correct dimensions for a square image", async () => { 53 + const result = await getImageSize("/path/to/square.jpg"); 54 + expect(result).toEqual({ width: 1080, height: 1080 }); 55 + }); 56 + 57 + test("should return correct dimensions for a landscape image", async () => { 58 + const result = await getImageSize("/path/to/landscape.jpg"); 59 + expect(result).toEqual({ width: 1920, height: 1080 }); 60 + }); 61 + 62 + test("should return correct dimensions for a portrait image", async () => { 63 + const result = await getImageSize("/path/to/portrait.jpg"); 64 + expect(result).toEqual({ width: 1080, height: 1920 }); 65 + }); 66 + 67 + test("should return null when metadata is missing width or height", async () => { 68 + const result = await getImageSize("/path/to/invalid.jpg"); 69 + expect(result).toBeNull(); 70 + }); 71 + 72 + test("should log error when image processing fails", async () => { 73 + // Import the logger mock 74 + const { logger } = require("../logger/logger"); 75 + 76 + // Call the function with a path that will trigger an error 77 + const result = await getImageSize("/path/to/missing.jpg"); 78 + 79 + // Verify the logger.error was called with the expected message pattern 80 + expect(logger.error).toHaveBeenCalled(); 81 + expect(logger.error).toHaveBeenCalledWith( 82 + expect.stringMatching( 83 + /Failed to get image aspect ratio; image path: \/path\/to\/missing\.jpg, error:/ 84 + ) 85 + ); 86 + 87 + // Verify the function returns null when an error occurs 88 + expect(result).toBeNull(); 89 + }); 90 + });
+1 -1
src/image/image.ts
··· 1 1 import sharp from "sharp"; 2 2 import byteSize from "byte-size"; 3 3 import { logger } from "../logger/logger"; 4 - import { Ratio } from "src/media"; 4 + import { Ratio } from "../media"; 5 5 6 6 /** 7 7 * Image lexicon maxSize 1mb
+10 -8
src/instagram-to-bluesky.test.ts
··· 6 6 calculateEstimatedTime, 7 7 uploadMediaAndEmbed, 8 8 } from "../src/instagram-to-bluesky"; 9 - import { BlueskyClient } from "../src/bluesky/bluesky"; 10 - import { logger } from "../src/logger/logger"; 11 - import { InstagramMediaProcessor } from "../src/media/media"; 12 - import { ImagesEmbedImpl, VideoEmbedImpl } from "../src/bluesky/index"; 13 - import type { InstagramExportedPost } from "../src/media/InstagramExportedPost"; 14 - import { ImageMediaProcessResultImpl } from "./media"; 9 + import { BlueskyClient } from "./bluesky/bluesky"; 10 + import { logger } from "./logger/logger"; 11 + import { InstagramMediaProcessor, ImageMediaProcessResultImpl, VideoMediaProcessResultImpl } from "./media"; 12 + import { ImagesEmbedImpl, VideoEmbedImpl } from "./bluesky/index"; 13 + import type { InstagramExportedPost } from "./media/InstagramExportedPost"; 15 14 16 15 // Mock all dependencies 17 16 jest.mock("fs"); 18 - jest.mock("../src/bluesky/bluesky", () => { 17 + jest.mock("./bluesky/bluesky", () => { 19 18 return { 20 19 BlueskyClient: jest.fn().mockImplementation(() => ({ 21 20 login: jest.fn().mockResolvedValue(undefined), ··· 28 27 })), 29 28 }; 30 29 }); 31 - jest.mock("../src/media/media", () => { 30 + jest.mock("./media", () => { 31 + const actual = jest.requireActual("./media") 32 32 const mockProcess = jest.fn().mockResolvedValue([ 33 33 { 34 34 postDate: new Date(), ··· 65 65 process: mockProcess, 66 66 })), 67 67 decodeUTF8: jest.fn((x) => x), 68 + ImageMediaProcessResultImpl: actual.ImageMediaProcessResultImpl, 69 + VideoMediaProcessResultImpl: actual.VideoMediaProcessResultImpl 68 70 }; 69 71 }); 70 72 jest.mock("../src/logger/logger", () => ({
+35 -27
src/instagram-to-bluesky.ts
··· 1 - import FS from 'fs'; 2 - import path from 'path'; 1 + import FS from "fs"; 2 + import path from "path"; 3 3 4 - import { BlobRef } from '@atproto/api'; 4 + import { BlobRef } from "@atproto/api"; 5 5 6 - import { BlueskyClient } from './bluesky/bluesky'; 6 + import { BlueskyClient } from "./bluesky/bluesky"; 7 + import { 8 + EmbeddedMedia, 9 + ImageEmbed, 10 + ImageEmbedImpl, 11 + ImagesEmbedImpl, 12 + VideoEmbedImpl, 13 + } from "./bluesky/index"; 14 + import { AppConfig } from "./config"; 15 + import { logger } from "./logger/logger"; 7 16 import { 8 - EmbeddedMedia, ImageEmbed, ImageEmbedImpl, ImagesEmbedImpl, VideoEmbedImpl 9 - } from './bluesky/index'; 10 - import { AppConfig } from './config'; 11 - import { logger } from './logger/logger'; 12 - import { ImageMediaProcessResultImpl, MediaProcessResult, VideoMediaProcessResultImpl } from './media'; 13 - import { InstagramExportedPost } from './media/InstagramExportedPost'; 14 - import { decodeUTF8, InstagramMediaProcessor } from './media/media'; 15 - 17 + ImageMediaProcessResultImpl, 18 + MediaProcessResult, 19 + VideoMediaProcessResultImpl, 20 + decodeUTF8, 21 + InstagramMediaProcessor, 22 + InstagramExportedPost, 23 + } from "./media"; 16 24 17 25 const API_RATE_LIMIT_DELAY = 3000; // https://docs.bsky.app/docs/advanced-guides/rate-limits 18 26 ··· 30 38 31 39 /** 32 40 * Uploads media files to Bluesky and creates appropriate embed objects 33 - * 41 + * 34 42 * This function processes an array of media files of the same type (either all images or all videos) 35 43 * and uploads them to Bluesky's servers. For images, it collects them into a single ImagesEmbed object. 36 44 * For videos, it creates a VideoEmbed object. If mixed media types are provided, only the first type 37 45 * encountered will be processed. 38 - * 46 + * 39 47 * @param postText - The text content of the post to be associated with the media 40 48 * @param embeddedMedia - Array of media objects to be processed and uploaded (should be same type) 41 49 * @param bluesky - The BlueskyClient instance used for uploading media 42 - * 50 + * 43 51 * @returns {Promise<{ 44 52 * importedMediaCount: number, // Number of successfully uploaded media files 45 53 * uploadedMedia: EmbeddedMedia | undefined // The final embed object for the post 46 54 * }>} 47 - * 55 + * 48 56 * @throws Will log but not throw errors from failed media uploads 49 - * 57 + * 50 58 * @example 51 59 * const result = await uploadMedia( 52 60 * "My vacation photos", ··· 69 77 for (const media of embeddedMedia) { 70 78 try { 71 79 if (media.getType() === "image") { 72 - const { mediaBuffer, mimeType, aspectRatio } = media as ImageMediaProcessResultImpl; 80 + const { mediaBuffer, mimeType, aspectRatio } = 81 + media as ImageMediaProcessResultImpl; 73 82 74 83 const blobRef: BlobRef = await bluesky.uploadMedia( 75 84 mediaBuffer!, 76 85 mimeType! 77 86 ); 78 - embeddedImages.push(new ImageEmbedImpl(postText, blobRef, mimeType!, aspectRatio)); 87 + embeddedImages.push( 88 + new ImageEmbedImpl(postText, blobRef, mimeType!, aspectRatio) 89 + ); 79 90 uploadedMedia = new ImagesEmbedImpl(embeddedImages); 80 91 } else if (media.getType() === "video") { 81 92 const { mediaBuffer, mimeType, aspectRatio } = ··· 143 154 // Decide where to fetch post data to process from. 144 155 let postsJsonPath: string; 145 156 if (config.isTestModeEnabled()) { 146 - postsJsonPath = path.join(archivalFolder, 'posts.json'); 157 + postsJsonPath = path.join(archivalFolder, "posts.json"); 147 158 logger.info( 148 159 `--- TEST mode is enabled, using content from ${archivalFolder} ---` 149 160 ); 150 161 } else { 151 162 postsJsonPath = path.join( 152 163 archivalFolder, 153 - 'your_instagram_activity/content/posts_1.json' 164 + "your_instagram_activity/content/posts_1.json" 154 165 ); 155 166 } 156 167 ··· 237 248 ); 238 249 try { 239 250 // Upload all the embedded media 240 - const { uploadedMedia, importedMediaCount } = await uploadMediaAndEmbed( 241 - postText, 242 - embeddedMedia, 243 - bluesky 244 - ); 251 + const { uploadedMedia, importedMediaCount } = 252 + await uploadMediaAndEmbed(postText, embeddedMedia, bluesky); 245 253 // Added uploaded media to the counter. 246 254 importedMedia += importedMediaCount; 247 255 ··· 259 267 importedPosts++; 260 268 } 261 269 } else { 262 - logger.warn('No media uploaded! Check Error logs.'); 270 + logger.warn("No media uploaded! Check Error logs."); 263 271 } 264 272 } catch (error) { 265 273 logger.error(
+12 -1
src/media/index.ts
··· 1 1 export * from './MediaProcessResult'; 2 2 export * from './ProcessedPost'; 3 - export * from './media'; 3 + export * from './InstagramExportedPost'; 4 + export * from './processors/InstagramMediaProcessor'; 5 + export * from './processors/InstagramImageProcessor'; 6 + export * from './processors/InstagramVideoProcessor'; 7 + export * from './processors/DefaultMediaProcessorFactory'; 8 + export * from './interfaces/ProcessStrategy'; 9 + export * from './interfaces/MIMEType'; 10 + export * from './interfaces/InstagramPostProcessingStrategy'; 11 + export * from './interfaces/ImageMediaProcessingStrategy'; 12 + export * from './interfaces/VideoMediaProcessingStrategy'; 13 + export * from './interfaces/MediaProcessorFactory'; 14 + export * from './utils';
+11 -264
src/media/media.test.ts src/media/processors/InstagramMediaProcessor.test.ts
··· 1 1 import fs from "fs"; 2 - 3 - import { InstagramImageProcessor, InstagramMediaProcessor, InstagramVideoProcessor, decodeUTF8 } from "./media"; 4 - import { getImageSize } from "../image"; 5 - import { InstagramExportedPost, VideoMedia, ImageMedia } from "./InstagramExportedPost"; 2 + import { InstagramMediaProcessor } from "./InstagramMediaProcessor"; 3 + import { InstagramExportedPost, VideoMedia, ImageMedia } from "../InstagramExportedPost"; 6 4 7 5 // Mock the file system 8 6 jest.mock("fs", () => ({ ··· 66 64 })); 67 65 68 66 // Mock the logger 69 - jest.mock("../logger/logger", () => ({ 67 + jest.mock("../../logger/logger", () => ({ 70 68 logger: { 71 69 error: jest.fn(), 72 70 warn: jest.fn(), ··· 76 74 })); 77 75 78 76 79 - // TODO breakdown into processors.test.ts or a test suite per processor instance. 80 - describe("Instagram Media Processing", () => { 81 - beforeEach(() => { 82 - jest.clearAllMocks(); 83 - (fs.readFileSync as jest.Mock).mockReturnValue(Buffer.from("test")); 84 - }); 85 - 86 - describe("InstagramMediaProcessor", () => { 77 + describe("InstagramMediaProcessor", () => { 87 78 const mockArchiveFolder = "/test/archive"; 88 - 79 + 80 + beforeEach(() => { 81 + jest.clearAllMocks(); 82 + (fs.readFileSync as jest.Mock).mockReturnValue(Buffer.from("test")); 83 + }); 84 + 89 85 test("should process a post with multiple images", async () => { 90 86 const mockPost: InstagramExportedPost = { 91 87 creation_timestamp: 1234567890, ··· 558 554 expect(result[0].postText).toContain("(Part 1/2)"); 559 555 expect(result[1].postText).toContain("(Part 2/2)"); 560 556 }); 561 - }); 562 - 563 - describe("InstagramImageProcessor", () => { 564 - test("should process multiple images", async () => { 565 - const mockImages: ImageMedia[] = [ 566 - { 567 - uri: "photo1.jpg", 568 - title: "Image 1", 569 - creation_timestamp: 1234567890, 570 - media_metadata: {}, 571 - cross_post_source: { source_app: "Instagram" }, 572 - backup_uri: "backup1.jpg", 573 - }, 574 - { 575 - uri: "photo2.jpg", 576 - title: "Image 2", 577 - creation_timestamp: 1234567890, 578 - media_metadata: {}, 579 - cross_post_source: { source_app: "Instagram" }, 580 - backup_uri: "backup2.jpg", 581 - }, 582 - ]; 583 - 584 - const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 585 - const result = await processor.process(); 586 - 587 - expect(result).toHaveLength(2); 588 - expect(result[0].mimeType).toBe("image/jpeg"); 589 - expect(result[1].mimeType).toBe("image/jpeg"); 590 - }); 591 - 592 - test("should handle unsupported image types", async () => { 593 - const mockImages: ImageMedia[] = [{ 594 - uri: "photo.xyz", 595 - title: "Invalid Image", 596 - creation_timestamp: 1234567890, 597 - media_metadata: {}, 598 - cross_post_source: { source_app: "Instagram" }, 599 - backup_uri: "backup_invalid.jpg", 600 - }]; 601 - 602 - const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 603 - const result = await processor.process(); 604 - 605 - expect(result).toHaveLength(1); 606 - expect(result[0].mimeType).toBe(""); 607 - }); 608 - 609 - test("should truncate image caption when it exceeds limit", async () => { 610 - const longCaption = "B".repeat(400); // Create a caption longer than POST_TEXT_LIMIT (300) 611 - const mockImages: ImageMedia[] = [{ 612 - uri: "photo1.jpg", 613 - title: longCaption, 614 - creation_timestamp: 1234567890, 615 - media_metadata: {}, 616 - cross_post_source: { source_app: "Instagram" }, 617 - backup_uri: "backup1.jpg", 618 - }]; 619 - 620 - const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 621 - const result = await processor.process(); 622 - 623 - expect(result).toHaveLength(1); 624 - expect(result[0].mediaText.length).toBe(300); // 297 chars + "..." 625 - expect(result[0].mediaText.endsWith("...")).toBe(true); 626 - }); 627 - 628 - test("should limit to maximum allowed images when processing multiple images", async () => { 629 - const mockImages: ImageMedia[] = Array(6).fill(null).map((_, index) => ({ 630 - uri: `photo${index + 1}.jpg`, 631 - title: `Image ${index + 1}`, 632 - creation_timestamp: 1234567890, 633 - media_metadata: {}, 634 - cross_post_source: { source_app: "Instagram" }, 635 - backup_uri: `backup${index + 1}.jpg`, 636 - })); 637 - 638 - const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 639 - const result = await processor.process(); 640 - 641 - // No longer limits images at this level 642 - expect(result).toHaveLength(6); 643 - result.forEach((media, index) => { 644 - expect(media.mimeType).toBe("image/jpeg"); 645 - expect(media.mediaText).toBe(`Image ${index + 1}`); 646 - }); 647 - }); 648 - }); 649 - 650 - describe("InstagramVideoProcessor", () => { 651 - test("should process a video", async () => { 652 - const mockVideo: VideoMedia = { 653 - uri: "video.mp4", 654 - title: "Test Video", 655 - creation_timestamp: 1234567890, 656 - media_metadata: {}, 657 - cross_post_source: { source_app: "Instagram" }, 658 - backup_uri: "backup_video.mp4", 659 - dubbing_info: [], 660 - media_variants: [], 661 - }; 662 - 663 - const processor = new InstagramVideoProcessor([mockVideo], "/test/archive"); 664 - const result = await processor.process(); 665 - 666 - expect(result).toHaveLength(1); 667 - expect(result[0].mimeType).toBe("video/mp4"); 668 - expect(result[0].mediaText).toBe("Test Video"); 669 - }); 670 - 671 - test("should handle unsupported video types", async () => { 672 - const mockVideo: VideoMedia = { 673 - uri: "video.xyz", 674 - title: "Invalid Video", 675 - creation_timestamp: 1234567890, 676 - media_metadata: {}, 677 - cross_post_source: { source_app: "Instagram" }, 678 - backup_uri: "backup_invalid.mp4", 679 - dubbing_info: [], 680 - media_variants: [], 681 - }; 682 - 683 - const processor = new InstagramVideoProcessor([mockVideo], "/test/archive"); 684 - const result = await processor.process(); 685 - 686 - expect(result).toHaveLength(1); 687 - expect(result[0].mimeType).toBe(""); 688 - }); 689 - 690 - test("should truncate video title when it exceeds limit", async () => { 691 - const longTitle = "C".repeat(400); // Create a title longer than POST_TEXT_LIMIT (300) 692 - const mockVideo: VideoMedia = { 693 - uri: "video.mp4", 694 - title: longTitle, 695 - creation_timestamp: 1234567890, 696 - media_metadata: {}, 697 - cross_post_source: { source_app: "Instagram" }, 698 - backup_uri: "backup_video.mp4", 699 - dubbing_info: [], 700 - media_variants: [], 701 - }; 702 - 703 - const processor = new InstagramVideoProcessor([mockVideo], "/test/archive"); 704 - const result = await processor.process(); 705 - 706 - expect(result).toHaveLength(1); 707 - expect(result[0].mediaText.length).toBe(303); // 300 chars + "..." 708 - expect(result[0].mediaText.endsWith("...")).toBe(true); 709 - }); 710 - }); 711 - 712 - describe("MediaProcessorFactory", () => { 713 - test("should use DefaultMediaProcessorFactory for image processing", async () => { 714 - const mockPost: InstagramExportedPost = { 715 - creation_timestamp: 1234567890, 716 - title: "Test Post", 717 - media: [ 718 - { 719 - uri: "test.jpg", 720 - title: "Test", 721 - creation_timestamp: 1234567890, 722 - media_metadata: {}, 723 - cross_post_source: { source_app: "Instagram" }, 724 - backup_uri: "backup_test.jpg", 725 - }, 726 - ] as ImageMedia[], 727 - }; 728 - 729 - const processor = new InstagramMediaProcessor([mockPost], "/test/archive"); 730 - const result = await processor.process(); 731 - 732 - expect(result).toHaveLength(1); 733 - expect(result[0].embeddedMedia).toBeDefined(); 734 - expect(Array.isArray(result[0].embeddedMedia)).toBe(true); 735 - }); 736 - 737 - test("should use DefaultMediaProcessorFactory for video processing", async () => { 738 - const mockPost: InstagramExportedPost = { 739 - creation_timestamp: 1234567890, 740 - title: "Test Video Post", 741 - media: [ 742 - { 743 - uri: "test.mp4", 744 - title: "Test Video", 745 - creation_timestamp: 1234567890, 746 - media_metadata: {}, 747 - cross_post_source: { source_app: "Instagram" }, 748 - backup_uri: "backup_test.mp4", 749 - dubbing_info: [], 750 - media_variants: [], 751 - }, 752 - ] as VideoMedia[], 753 - }; 754 - 755 - const processor = new InstagramMediaProcessor([mockPost], "/test/archive"); 756 - const result = await processor.process(); 757 - 758 - expect(result).toHaveLength(1); 759 - expect(result[0].embeddedMedia).toBeDefined(); 760 - expect(Array.isArray(result[0].embeddedMedia)).toBe(true); 761 - }); 762 - }); 763 - }); 764 - 765 - describe("decodeUTF8", () => { 766 - test("should decode Instagram Unicode escape sequences", () => { 767 - const input = "Basil, Eucalyptus, Thyme \u00f0\u009f\u0098\u008d\u00f0\u009f\u008c\u00b1"; 768 - const result = decodeUTF8(input); 769 - expect(result).toBe("Basil, Eucalyptus, Thyme 😍🌱"); 770 - }); 771 - }); 772 - 773 - describe("getImageSize", () => { 774 - test("should return correct dimensions for a square image", async () => { 775 - const result = await getImageSize("/path/to/square.jpg"); 776 - expect(result).toEqual({ width: 1080, height: 1080 }); 777 - }); 778 - 779 - test("should return correct dimensions for a landscape image", async () => { 780 - const result = await getImageSize("/path/to/landscape.jpg"); 781 - expect(result).toEqual({ width: 1920, height: 1080 }); 782 - }); 783 - 784 - test("should return correct dimensions for a portrait image", async () => { 785 - const result = await getImageSize("/path/to/portrait.jpg"); 786 - expect(result).toEqual({ width: 1080, height: 1920 }); 787 - }); 788 - 789 - test("should return null when metadata is missing width or height", async () => { 790 - const result = await getImageSize("/path/to/invalid.jpg"); 791 - expect(result).toBeNull(); 792 - }); 793 - 794 - test("should log error when image processing fails", async () => { 795 - // Import the logger mock 796 - const { logger } = require("../logger/logger"); 797 - 798 - // Call the function with a path that will trigger an error 799 - const result = await getImageSize("/path/to/missing.jpg"); 800 - 801 - // Verify the logger.error was called with the expected message pattern 802 - expect(logger.error).toHaveBeenCalled(); 803 - expect(logger.error).toHaveBeenCalledWith( 804 - expect.stringMatching(/Failed to get image aspect ratio; image path: \/path\/to\/missing\.jpg, error:/) 805 - ); 806 - 807 - // Verify the function returns null when an error occurs 808 - expect(result).toBeNull(); 809 - }); 810 - }); 557 + });
-11
src/media/media.ts
··· 1 - export * from './processors/InstagramMediaProcessor'; 2 - export * from './processors/InstagramImageProcessor'; 3 - export * from './processors/InstagramVideoProcessor'; 4 - export * from './processors/DefaultMediaProcessorFactory'; 5 - export * from './interfaces/ProcessStrategy'; 6 - export * from './interfaces/MIMEType'; 7 - export * from './interfaces/InstagramPostProcessingStrategy'; 8 - export * from './interfaces/ImageMediaProcessingStrategy'; 9 - export * from './interfaces/VideoMediaProcessingStrategy'; 10 - export * from './interfaces/MediaProcessorFactory'; 11 - export * from './utils';
+137
src/media/processors/Factory.test.ts
··· 1 + import fs from "fs"; 2 + import { InstagramMediaProcessor } from "../"; 3 + import { 4 + InstagramExportedPost, 5 + VideoMedia, 6 + ImageMedia, 7 + } from "../InstagramExportedPost"; 8 + 9 + // Mock the file system 10 + jest.mock("fs", () => ({ 11 + readFileSync: jest.fn(), 12 + })); 13 + 14 + // Mock sharp 15 + jest.mock("sharp", () => { 16 + return function (filePath: string) { 17 + // Mock different behavior based on file path for testing different scenarios 18 + if (filePath && filePath.includes("missing.jpg")) { 19 + throw new Error("Input file is missing"); 20 + } 21 + 22 + if (filePath && filePath.includes("invalid.jpg")) { 23 + return { 24 + metadata: jest.fn().mockResolvedValue({}), 25 + }; 26 + } 27 + 28 + if (filePath && filePath.includes("landscape.jpg")) { 29 + return { 30 + metadata: jest.fn().mockResolvedValue({ width: 1920, height: 1080 }), 31 + }; 32 + } 33 + 34 + if (filePath && filePath.includes("portrait.jpg")) { 35 + return { 36 + metadata: jest.fn().mockResolvedValue({ width: 1080, height: 1920 }), 37 + }; 38 + } 39 + 40 + // Default square image 41 + return { 42 + metadata: jest.fn().mockResolvedValue({ width: 1080, height: 1080 }), 43 + }; 44 + }; 45 + }); 46 + 47 + // Mock fluent-ffmpeg 48 + jest.mock("fluent-ffmpeg", () => { 49 + return { 50 + setFfprobePath: jest.fn(), 51 + ffprobe: ( 52 + path: string, 53 + callback: (err: Error | null, data: any) => void 54 + ) => { 55 + callback(null, { 56 + streams: [ 57 + { 58 + codec_type: "video", 59 + width: 1920, 60 + height: 1080, 61 + }, 62 + ], 63 + }); 64 + }, 65 + }; 66 + }); 67 + 68 + // Mock @ffprobe-installer/ffprobe 69 + jest.mock("@ffprobe-installer/ffprobe", () => ({ 70 + path: "/mock/ffprobe/path", 71 + })); 72 + 73 + // Mock the logger 74 + jest.mock("../../logger/logger", () => ({ 75 + logger: { 76 + error: jest.fn(), 77 + warn: jest.fn(), 78 + info: jest.fn(), 79 + debug: jest.fn(), 80 + }, 81 + })); 82 + 83 + describe("MediaProcessorFactory", () => { 84 + beforeEach(() => { 85 + jest.clearAllMocks(); 86 + (fs.readFileSync as jest.Mock).mockReturnValue(Buffer.from("test")); 87 + }); 88 + test("should use DefaultMediaProcessorFactory for image processing", async () => { 89 + const mockPost: InstagramExportedPost = { 90 + creation_timestamp: 1234567890, 91 + title: "Test Post", 92 + media: [ 93 + { 94 + uri: "test.jpg", 95 + title: "Test", 96 + creation_timestamp: 1234567890, 97 + media_metadata: {}, 98 + cross_post_source: { source_app: "Instagram" }, 99 + backup_uri: "backup_test.jpg", 100 + }, 101 + ] as ImageMedia[], 102 + }; 103 + 104 + const processor = new InstagramMediaProcessor([mockPost], "/test/archive"); 105 + const result = await processor.process(); 106 + 107 + expect(result).toHaveLength(1); 108 + expect(result[0].embeddedMedia).toBeDefined(); 109 + expect(Array.isArray(result[0].embeddedMedia)).toBe(true); 110 + }); 111 + 112 + test("should use DefaultMediaProcessorFactory for video processing", async () => { 113 + const mockPost: InstagramExportedPost = { 114 + creation_timestamp: 1234567890, 115 + title: "Test Video Post", 116 + media: [ 117 + { 118 + uri: "test.mp4", 119 + title: "Test Video", 120 + creation_timestamp: 1234567890, 121 + media_metadata: {}, 122 + cross_post_source: { source_app: "Instagram" }, 123 + backup_uri: "backup_test.mp4", 124 + dubbing_info: [], 125 + media_variants: [], 126 + }, 127 + ] as VideoMedia[], 128 + }; 129 + 130 + const processor = new InstagramMediaProcessor([mockPost], "/test/archive"); 131 + const result = await processor.process(); 132 + 133 + expect(result).toHaveLength(1); 134 + expect(result[0].embeddedMedia).toBeDefined(); 135 + expect(Array.isArray(result[0].embeddedMedia)).toBe(true); 136 + }); 137 + });
+161
src/media/processors/InstagramImageProcessor.test.ts
··· 1 + import { InstagramImageProcessor } from "../"; 2 + import { ImageMedia } from "../InstagramExportedPost"; 3 + 4 + // Mock the file system 5 + jest.mock("fs", () => ({ 6 + readFileSync: jest.fn(), 7 + })); 8 + 9 + // Mock sharp 10 + jest.mock("sharp", () => { 11 + return function(filePath: string) { 12 + // Mock different behavior based on file path for testing different scenarios 13 + if (filePath && filePath.includes('missing.jpg')) { 14 + throw new Error('Input file is missing'); 15 + } 16 + 17 + if (filePath && filePath.includes('invalid.jpg')) { 18 + return { 19 + metadata: jest.fn().mockResolvedValue({}) 20 + }; 21 + } 22 + 23 + if (filePath && filePath.includes('landscape.jpg')) { 24 + return { 25 + metadata: jest.fn().mockResolvedValue({ width: 1920, height: 1080 }) 26 + }; 27 + } 28 + 29 + if (filePath && filePath.includes('portrait.jpg')) { 30 + return { 31 + metadata: jest.fn().mockResolvedValue({ width: 1080, height: 1920 }) 32 + }; 33 + } 34 + 35 + // Default square image 36 + return { 37 + metadata: jest.fn().mockResolvedValue({ width: 1080, height: 1080 }) 38 + }; 39 + }; 40 + }); 41 + 42 + // Mock fluent-ffmpeg 43 + jest.mock("fluent-ffmpeg", () => { 44 + return { 45 + setFfprobePath: jest.fn(), 46 + ffprobe: (path: string, callback: (err: Error | null, data: any) => void) => { 47 + callback(null, { 48 + streams: [ 49 + { 50 + codec_type: "video", 51 + width: 1920, 52 + height: 1080, 53 + }, 54 + ], 55 + }); 56 + }, 57 + }; 58 + }); 59 + 60 + // Mock @ffprobe-installer/ffprobe 61 + jest.mock("@ffprobe-installer/ffprobe", () => ({ 62 + path: "/mock/ffprobe/path", 63 + })); 64 + 65 + // Mock the logger 66 + jest.mock("../../logger/logger", () => ({ 67 + logger: { 68 + error: jest.fn(), 69 + warn: jest.fn(), 70 + info: jest.fn(), 71 + debug: jest.fn(), 72 + }, 73 + })); 74 + 75 + 76 + describe("InstagramImageProcessor", () => { 77 + test("should process multiple images", async () => { 78 + const mockImages: ImageMedia[] = [ 79 + { 80 + uri: "photo1.jpg", 81 + title: "Image 1", 82 + creation_timestamp: 1234567890, 83 + media_metadata: {}, 84 + cross_post_source: { source_app: "Instagram" }, 85 + backup_uri: "backup1.jpg", 86 + }, 87 + { 88 + uri: "photo2.jpg", 89 + title: "Image 2", 90 + creation_timestamp: 1234567890, 91 + media_metadata: {}, 92 + cross_post_source: { source_app: "Instagram" }, 93 + backup_uri: "backup2.jpg", 94 + }, 95 + ]; 96 + 97 + const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 98 + const result = await processor.process(); 99 + 100 + expect(result).toHaveLength(2); 101 + expect(result[0].mimeType).toBe("image/jpeg"); 102 + expect(result[1].mimeType).toBe("image/jpeg"); 103 + }); 104 + 105 + test("should handle unsupported image types", async () => { 106 + const mockImages: ImageMedia[] = [{ 107 + uri: "photo.xyz", 108 + title: "Invalid Image", 109 + creation_timestamp: 1234567890, 110 + media_metadata: {}, 111 + cross_post_source: { source_app: "Instagram" }, 112 + backup_uri: "backup_invalid.jpg", 113 + }]; 114 + 115 + const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 116 + const result = await processor.process(); 117 + 118 + expect(result).toHaveLength(1); 119 + expect(result[0].mimeType).toBe(""); 120 + }); 121 + 122 + test("should truncate image caption when it exceeds limit", async () => { 123 + const longCaption = "B".repeat(400); // Create a caption longer than POST_TEXT_LIMIT (300) 124 + const mockImages: ImageMedia[] = [{ 125 + uri: "photo1.jpg", 126 + title: longCaption, 127 + creation_timestamp: 1234567890, 128 + media_metadata: {}, 129 + cross_post_source: { source_app: "Instagram" }, 130 + backup_uri: "backup1.jpg", 131 + }]; 132 + 133 + const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 134 + const result = await processor.process(); 135 + 136 + expect(result).toHaveLength(1); 137 + expect(result[0].mediaText.length).toBe(300); // 297 chars + "..." 138 + expect(result[0].mediaText.endsWith("...")).toBe(true); 139 + }); 140 + 141 + test("should limit to maximum allowed images when processing multiple images", async () => { 142 + const mockImages: ImageMedia[] = Array(6).fill(null).map((_, index) => ({ 143 + uri: `photo${index + 1}.jpg`, 144 + title: `Image ${index + 1}`, 145 + creation_timestamp: 1234567890, 146 + media_metadata: {}, 147 + cross_post_source: { source_app: "Instagram" }, 148 + backup_uri: `backup${index + 1}.jpg`, 149 + })); 150 + 151 + const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 152 + const result = await processor.process(); 153 + 154 + // No longer limits images at this level 155 + expect(result).toHaveLength(6); 156 + result.forEach((media, index) => { 157 + expect(media.mimeType).toBe("image/jpeg"); 158 + expect(media.mediaText).toBe(`Image ${index + 1}`); 159 + }); 160 + }); 161 + });
+1
src/media/processors/InstagramMediaProcessor.ts
··· 9 9 const POST_TEXT_LIMIT = 300; 10 10 const POST_TEXT_TRUNCATE_SUFFIX = "..."; 11 11 12 + // TODO log split in debug. 12 13 export class InstagramMediaProcessor implements InstagramPostProcessingStrategy { 13 14 readonly mediaProcessorFactory: MediaProcessorFactory; 14 15
+144
src/media/processors/InstagramVideoProcessor.test.ts
··· 1 + import fs from "fs"; 2 + import { VideoMedia } from "../InstagramExportedPost"; 3 + import { InstagramVideoProcessor } from ".."; 4 + 5 + // Mock the file system 6 + jest.mock("fs", () => ({ 7 + readFileSync: jest.fn(), 8 + })); 9 + 10 + // Mock sharp 11 + jest.mock("sharp", () => { 12 + return function (filePath: string) { 13 + // Mock different behavior based on file path for testing different scenarios 14 + if (filePath && filePath.includes("missing.jpg")) { 15 + throw new Error("Input file is missing"); 16 + } 17 + 18 + if (filePath && filePath.includes("invalid.jpg")) { 19 + return { 20 + metadata: jest.fn().mockResolvedValue({}), 21 + }; 22 + } 23 + 24 + if (filePath && filePath.includes("landscape.jpg")) { 25 + return { 26 + metadata: jest.fn().mockResolvedValue({ width: 1920, height: 1080 }), 27 + }; 28 + } 29 + 30 + if (filePath && filePath.includes("portrait.jpg")) { 31 + return { 32 + metadata: jest.fn().mockResolvedValue({ width: 1080, height: 1920 }), 33 + }; 34 + } 35 + 36 + // Default square image 37 + return { 38 + metadata: jest.fn().mockResolvedValue({ width: 1080, height: 1080 }), 39 + }; 40 + }; 41 + }); 42 + 43 + // Mock fluent-ffmpeg 44 + jest.mock("fluent-ffmpeg", () => { 45 + return { 46 + setFfprobePath: jest.fn(), 47 + ffprobe: ( 48 + path: string, 49 + callback: (err: Error | null, data: any) => void 50 + ) => { 51 + callback(null, { 52 + streams: [ 53 + { 54 + codec_type: "video", 55 + width: 1920, 56 + height: 1080, 57 + }, 58 + ], 59 + }); 60 + }, 61 + }; 62 + }); 63 + 64 + // Mock @ffprobe-installer/ffprobe 65 + jest.mock("@ffprobe-installer/ffprobe", () => ({ 66 + path: "/mock/ffprobe/path", 67 + })); 68 + 69 + // Mock the logger 70 + jest.mock("../../logger/logger", () => ({ 71 + logger: { 72 + error: jest.fn(), 73 + warn: jest.fn(), 74 + info: jest.fn(), 75 + debug: jest.fn(), 76 + }, 77 + })); 78 + 79 + describe("InstagramVideoProcessor", () => { 80 + beforeEach(() => { 81 + jest.clearAllMocks(); 82 + (fs.readFileSync as jest.Mock).mockReturnValue(Buffer.from("test")); 83 + }); 84 + 85 + test("should process a video", async () => { 86 + const mockVideo: VideoMedia = { 87 + uri: "video.mp4", 88 + title: "Test Video", 89 + creation_timestamp: 1234567890, 90 + media_metadata: {}, 91 + cross_post_source: { source_app: "Instagram" }, 92 + backup_uri: "backup_video.mp4", 93 + dubbing_info: [], 94 + media_variants: [], 95 + }; 96 + 97 + const processor = new InstagramVideoProcessor([mockVideo], "/test/archive"); 98 + const result = await processor.process(); 99 + 100 + expect(result).toHaveLength(1); 101 + expect(result[0].mimeType).toBe("video/mp4"); 102 + expect(result[0].mediaText).toBe("Test Video"); 103 + }); 104 + 105 + test("should handle unsupported video types", async () => { 106 + const mockVideo: VideoMedia = { 107 + uri: "video.xyz", 108 + title: "Invalid Video", 109 + creation_timestamp: 1234567890, 110 + media_metadata: {}, 111 + cross_post_source: { source_app: "Instagram" }, 112 + backup_uri: "backup_invalid.mp4", 113 + dubbing_info: [], 114 + media_variants: [], 115 + }; 116 + 117 + const processor = new InstagramVideoProcessor([mockVideo], "/test/archive"); 118 + const result = await processor.process(); 119 + 120 + expect(result).toHaveLength(1); 121 + expect(result[0].mimeType).toBe(""); 122 + }); 123 + 124 + test("should truncate video title when it exceeds limit", async () => { 125 + const longTitle = "C".repeat(400); // Create a title longer than POST_TEXT_LIMIT (300) 126 + const mockVideo: VideoMedia = { 127 + uri: "video.mp4", 128 + title: longTitle, 129 + creation_timestamp: 1234567890, 130 + media_metadata: {}, 131 + cross_post_source: { source_app: "Instagram" }, 132 + backup_uri: "backup_video.mp4", 133 + dubbing_info: [], 134 + media_variants: [], 135 + }; 136 + 137 + const processor = new InstagramVideoProcessor([mockVideo], "/test/archive"); 138 + const result = await processor.process(); 139 + 140 + expect(result).toHaveLength(1); 141 + expect(result[0].mediaText.length).toBe(303); // 300 chars + "..." 142 + expect(result[0].mediaText.endsWith("...")).toBe(true); 143 + }); 144 + });
+10
src/media/utils.test.ts
··· 1 + import { decodeUTF8 } from "./utils"; 2 + 3 + describe("decodeUTF8", () => { 4 + test("should decode Instagram Unicode escape sequences", () => { 5 + const input = 6 + "Basil, Eucalyptus, Thyme \u00f0\u009f\u0098\u008d\u00f0\u009f\u008c\u00b1"; 7 + const result = decodeUTF8(input); 8 + expect(result).toBe("Basil, Eucalyptus, Thyme 😍🌱"); 9 + }); 10 + });