diff --git a/readeckTests/OfflineCacheRepositoryTests.swift b/readeckTests/OfflineCacheRepositoryTests.swift new file mode 100644 index 0000000..6262dc0 --- /dev/null +++ b/readeckTests/OfflineCacheRepositoryTests.swift @@ -0,0 +1,347 @@ +// +// OfflineCacheRepositoryTests.swift +// readeckTests +// +// Created by Claude on 21.11.25. +// + +import Testing +import Foundation +import CoreData +@testable import readeck + +@Suite("OfflineCacheRepository Tests") +struct OfflineCacheRepositoryTests { + + // MARK: - Test Setup + + private func createInMemoryCoreDataStack() -> NSManagedObjectContext { + let managedObjectModel = NSManagedObjectModel.mergedModel(from: [Bundle.main])! + let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel) + + try! persistentStoreCoordinator.addPersistentStore( + ofType: NSInMemoryStoreType, + configurationName: nil, + at: nil, + options: nil + ) + + let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + context.persistentStoreCoordinator = persistentStoreCoordinator + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + return context + } + + private func createTestBookmark(id: String = "test-123", title: String = "Test Article") -> Bookmark { + return Bookmark( + id: id, + title: title, + url: "https://example.com/article", + href: "/api/bookmarks/\(id)", + description: "Test description", + authors: [], + created: ISO8601DateFormatter().string(from: Date()), + published: nil, + updated: ISO8601DateFormatter().string(from: Date()), + siteName: "Example Site", + site: "example.com", + readingTime: 5, + wordCount: 1000, + hasArticle: true, + isArchived: false, + isDeleted: false, + isMarked: false, + labels: [], + lang: "en", + loaded: true, + readProgress: 0, + documentType: "article", + state: 0, + textDirection: "ltr", + type: "bookmark", + resources: BookmarkResources( + article: Resource(src: "/api/bookmarks/\(id)/article"), + icon: nil, + image: nil, + log: nil, + props: nil, + thumbnail: nil + ) + ) + } + + // MARK: - HTML Extraction Tests + + @Test("Extract image URLs from HTML correctly") + func testExtractImageURLsFromHTML() { + let html = """ + + + Image 1 + + Relative + + + + """ + + // We need to test the private method indirectly via cacheBookmarkWithMetadata + // For now, we'll test the regex pattern separately + let pattern = #"]+src=\"([^\"]+)\""# + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let nsString = html as NSString + let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length)) + + var imageURLs: [String] = [] + for result in results { + if result.numberOfRanges >= 2 { + let urlRange = result.range(at: 1) + if let url = nsString.substring(with: urlRange) as String?, + url.hasPrefix("http") { + imageURLs.append(url) + } + } + } + + #expect(imageURLs.count == 3) + #expect(imageURLs.contains("https://example.com/image1.jpg")) + #expect(imageURLs.contains("https://example.com/image2.png")) + #expect(imageURLs.contains("https://example.com/image3.gif")) + #expect(!imageURLs.contains("/relative/image.jpg")) + } + + @Test("Extract image URLs handles empty HTML") + func testExtractImageURLsFromEmptyHTML() { + let html = "

No images here

" + + let pattern = #"]+src=\"([^\"]+)\""# + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: html.count)) + + #expect(results.count == 0) + } + + @Test("Extract image URLs handles malformed HTML") + func testExtractImageURLsFromMalformedHTML() { + let html = """ + + + + """ + + let pattern = #"]+src=\"([^\"]+)\""# + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: html.count)) + + // Should only match double-quoted URLs + #expect(results.count == 1) + } + + // MARK: - Cache Size Calculation Tests + + @Test("Cache size calculation is accurate") + func testCacheSizeCalculation() { + let html = "Test HTML content" + let expectedSize = Int64(html.utf8.count) + + #expect(expectedSize == 17) + } + + @Test("Cache size handles empty content") + func testCacheSizeWithEmptyContent() { + let html = "" + let expectedSize = Int64(html.utf8.count) + + #expect(expectedSize == 0) + } + + @Test("Cache size handles UTF-8 characters correctly") + func testCacheSizeWithUTF8Characters() { + let html = "Hello δΈ–η•Œ 🌍" + let expectedSize = Int64(html.utf8.count) + + // UTF-8: "Hello " (6) + "δΈ–η•Œ" (6) + " " (1) + "🌍" (4) = 17 bytes + #expect(expectedSize > html.count) // More bytes than characters + } + + // MARK: - Image URL Storage Tests + + @Test("Image URLs are joined correctly with comma separator") + func testImageURLsJoining() { + let imageURLs = [ + "https://example.com/image1.jpg", + "https://example.com/image2.png", + "https://example.com/image3.gif" + ] + + let joined = imageURLs.joined(separator: ",") + #expect(joined == "https://example.com/image1.jpg,https://example.com/image2.png,https://example.com/image3.gif") + + // Test splitting + let split = joined.split(separator: ",").map(String.init) + #expect(split.count == 3) + #expect(split == imageURLs) + } + + @Test("Image URLs splitting handles empty string") + func testImageURLsSplittingEmptyString() { + let imageURLsString = "" + let split = imageURLsString.split(separator: ",").map(String.init) + + #expect(split.isEmpty) + } + + @Test("Image URLs splitting handles single URL") + func testImageURLsSplittingSingleURL() { + let imageURLsString = "https://example.com/single.jpg" + let split = imageURLsString.split(separator: ",").map(String.init) + + #expect(split.count == 1) + #expect(split.first == "https://example.com/single.jpg") + } + + // MARK: - Bookmark Domain Model Tests + + @Test("Bookmark creation has correct defaults") + func testBookmarkCreation() { + let bookmark = createTestBookmark() + + #expect(bookmark.id == "test-123") + #expect(bookmark.title == "Test Article") + #expect(bookmark.url == "https://example.com/article") + #expect(bookmark.readProgress == 0) + #expect(bookmark.isMarked == false) + } + + // MARK: - FIFO Cleanup Logic Tests + + @Test("FIFO cleanup calculates correct number of items to delete") + func testFIFOCleanupCalculation() { + let totalCount = 30 + let keepCount = 20 + let expectedDeleteCount = totalCount - keepCount + + #expect(expectedDeleteCount == 10) + } + + @Test("FIFO cleanup does not delete when under limit") + func testFIFOCleanupUnderLimit() { + let totalCount = 15 + let keepCount = 20 + + if totalCount > keepCount { + #expect(Bool(false), "Should not trigger cleanup") + } else { + #expect(Bool(true), "Cleanup should be skipped") + } + } + + @Test("FIFO cleanup deletes all items when keepCount is zero") + func testFIFOCleanupKeepZero() { + let totalCount = 10 + let keepCount = 0 + let expectedDeleteCount = totalCount - keepCount + + #expect(expectedDeleteCount == 10) + } + + // MARK: - Date Handling Tests + + @Test("Cache date and access date are set correctly") + func testCacheDates() { + let now = Date() + let cachedDate = now + let lastAccessDate = now + + #expect(cachedDate.timeIntervalSince1970 == now.timeIntervalSince1970) + #expect(lastAccessDate.timeIntervalSince1970 == now.timeIntervalSince1970) + } + + @Test("Last access date updates on read") + func testLastAccessDateUpdate() { + let initialDate = Date(timeIntervalSince1970: 1000) + let updatedDate = Date(timeIntervalSince1970: 2000) + + #expect(updatedDate > initialDate) + #expect(updatedDate.timeIntervalSince1970 - initialDate.timeIntervalSince1970 == 1000) + } + + // MARK: - ByteCountFormatter Tests + + @Test("ByteCountFormatter formats small sizes correctly") + func testByteCountFormatterSmallSizes() { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + + let bytes1KB = Int64(1024) + let formatted1KB = formatter.string(fromByteCount: bytes1KB) + #expect(formatted1KB.contains("KB") || formatted1KB.contains("kB")) + + let bytes10KB = Int64(10 * 1024) + let formatted10KB = formatter.string(fromByteCount: bytes10KB) + #expect(formatted10KB.contains("KB") || formatted10KB.contains("kB")) + } + + @Test("ByteCountFormatter formats large sizes correctly") + func testByteCountFormatterLargeSizes() { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + + let bytes1MB = Int64(1024 * 1024) + let formatted1MB = formatter.string(fromByteCount: bytes1MB) + #expect(formatted1MB.contains("MB")) + + let bytes100MB = Int64(100 * 1024 * 1024) + let formatted100MB = formatter.string(fromByteCount: bytes100MB) + #expect(formatted100MB.contains("MB")) + } + + @Test("ByteCountFormatter handles zero bytes") + func testByteCountFormatterZero() { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + + let formatted = formatter.string(fromByteCount: 0) + #expect(formatted.contains("0") || formatted.contains("Zero")) + } + + // MARK: - NSPredicate Tests + + @Test("Cache filter predicate syntax is correct") + func testCacheFilterPredicate() { + let predicate = NSPredicate(format: "htmlContent != nil") + + // Test with mock data + let testData = ["htmlContent": "Some HTML"] + let result = predicate.evaluate(with: testData) + #expect(result == true) + } + + @Test("ID filter predicate syntax is correct") + func testIDFilterPredicate() { + let testID = "test-123" + let predicate = NSPredicate(format: "id == %@", testID) + + let testData = ["id": "test-123"] + let result = predicate.evaluate(with: testData) + #expect(result == true) + + let wrongData = ["id": "wrong-id"] + let wrongResult = predicate.evaluate(with: wrongData) + #expect(wrongResult == false) + } + + @Test("Combined cache and ID predicate is correct") + func testCombinedPredicate() { + let testID = "test-123" + let predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", testID) + + let validData = ["id": "test-123", "htmlContent": "HTML"] + let validResult = predicate.evaluate(with: validData) + #expect(validResult == true) + + let missingHTML = ["id": "test-123", "htmlContent": nil as String?] + let missingHTMLResult = predicate.evaluate(with: missingHTML) + #expect(missingHTMLResult == false) + } +} diff --git a/readeckTests/OfflineSettingsTests.swift b/readeckTests/OfflineSettingsTests.swift new file mode 100644 index 0000000..4f9f320 --- /dev/null +++ b/readeckTests/OfflineSettingsTests.swift @@ -0,0 +1,185 @@ +// +// OfflineSettingsTests.swift +// readeckTests +// +// Created by Claude on 21.11.25. +// + +import Testing +import Foundation +@testable import readeck + +@Suite("OfflineSettings Tests") +struct OfflineSettingsTests { + + // MARK: - Initialization Tests + + @Test("Default initialization has correct values") + func testDefaultInitialization() { + let settings = OfflineSettings() + + #expect(settings.enabled == true) + #expect(settings.maxUnreadArticles == 20.0) + #expect(settings.saveImages == false) + #expect(settings.lastSyncDate == nil) + #expect(settings.maxUnreadArticlesInt == 20) + } + + // MARK: - maxUnreadArticlesInt Tests + + @Test("maxUnreadArticlesInt converts Double to Int correctly") + func testMaxUnreadArticlesIntConversion() { + var settings = OfflineSettings() + + settings.maxUnreadArticles = 15.0 + #expect(settings.maxUnreadArticlesInt == 15) + + settings.maxUnreadArticles = 50.7 + #expect(settings.maxUnreadArticlesInt == 50) + + settings.maxUnreadArticles = 99.9 + #expect(settings.maxUnreadArticlesInt == 99) + } + + // MARK: - shouldSyncOnAppStart Tests + + @Test("shouldSyncOnAppStart returns false when disabled") + func testShouldNotSyncWhenDisabled() { + var settings = OfflineSettings() + settings.enabled = false + settings.lastSyncDate = nil // Never synced + + #expect(settings.shouldSyncOnAppStart == false) + } + + @Test("shouldSyncOnAppStart returns true when never synced") + func testShouldSyncWhenNeverSynced() { + var settings = OfflineSettings() + settings.enabled = true + settings.lastSyncDate = nil + + #expect(settings.shouldSyncOnAppStart == true) + } + + @Test("shouldSyncOnAppStart returns true when last sync was more than 4 hours ago") + func testShouldSyncWhenLastSyncOlderThan4Hours() { + var settings = OfflineSettings() + settings.enabled = true + + // Test with 5 hours ago + settings.lastSyncDate = Date().addingTimeInterval(-5 * 60 * 60) + #expect(settings.shouldSyncOnAppStart == true) + + // Test with 4.5 hours ago + settings.lastSyncDate = Date().addingTimeInterval(-4.5 * 60 * 60) + #expect(settings.shouldSyncOnAppStart == true) + + // Test with exactly 4 hours + 1 second ago + settings.lastSyncDate = Date().addingTimeInterval(-4 * 60 * 60 - 1) + #expect(settings.shouldSyncOnAppStart == true) + } + + @Test("shouldSyncOnAppStart returns false when last sync was less than 4 hours ago") + func testShouldNotSyncWhenLastSyncWithin4Hours() { + var settings = OfflineSettings() + settings.enabled = true + + // Test with 3 hours ago + settings.lastSyncDate = Date().addingTimeInterval(-3 * 60 * 60) + #expect(settings.shouldSyncOnAppStart == false) + + // Test with 1 hour ago + settings.lastSyncDate = Date().addingTimeInterval(-1 * 60 * 60) + #expect(settings.shouldSyncOnAppStart == false) + + // Test with 1 minute ago + settings.lastSyncDate = Date().addingTimeInterval(-60) + #expect(settings.shouldSyncOnAppStart == false) + + // Test with just now + settings.lastSyncDate = Date() + #expect(settings.shouldSyncOnAppStart == false) + } + + @Test("shouldSyncOnAppStart boundary test near 4 hours") + func testShouldSyncBoundaryAt4Hours() { + var settings = OfflineSettings() + settings.enabled = true + + // Test slightly under 4 hours (3h 59m 30s) - should NOT sync + settings.lastSyncDate = Date().addingTimeInterval(-3 * 60 * 60 - 59 * 60 - 30) + #expect(settings.shouldSyncOnAppStart == false) + + // Test slightly over 4 hours (4h 0m 30s) - should sync + settings.lastSyncDate = Date().addingTimeInterval(-4 * 60 * 60 - 30) + #expect(settings.shouldSyncOnAppStart == true) + } + + @Test("shouldSyncOnAppStart with future date edge case") + func testShouldNotSyncWithFutureDate() { + var settings = OfflineSettings() + settings.enabled = true + + // Edge case: lastSyncDate in the future (clock skew/bug) + settings.lastSyncDate = Date().addingTimeInterval(60 * 60) // 1 hour in future + #expect(settings.shouldSyncOnAppStart == false) + } + + // MARK: - Codable Tests + + @Test("OfflineSettings is encodable and decodable") + func testCodableRoundTrip() throws { + var original = OfflineSettings() + original.enabled = false + original.maxUnreadArticles = 35.0 + original.saveImages = true + original.lastSyncDate = Date(timeIntervalSince1970: 1699999999) + + let encoder = JSONEncoder() + let data = try encoder.encode(original) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(OfflineSettings.self, from: data) + + #expect(decoded.enabled == original.enabled) + #expect(decoded.maxUnreadArticles == original.maxUnreadArticles) + #expect(decoded.saveImages == original.saveImages) + #expect(decoded.lastSyncDate?.timeIntervalSince1970 == original.lastSyncDate?.timeIntervalSince1970) + } + + @Test("OfflineSettings decodes with missing optional fields") + func testDecodingWithMissingFields() throws { + let json = """ + { + "enabled": true, + "maxUnreadArticles": 25.0, + "saveImages": false + } + """ + + let data = json.data(using: .utf8)! + let decoder = JSONDecoder() + let settings = try decoder.decode(OfflineSettings.self, from: data) + + #expect(settings.enabled == true) + #expect(settings.maxUnreadArticles == 25.0) + #expect(settings.saveImages == false) + #expect(settings.lastSyncDate == nil) + } + + // MARK: - Edge Cases + + @Test("maxUnreadArticles handles extreme values") + func testMaxUnreadArticlesExtremeValues() { + var settings = OfflineSettings() + + settings.maxUnreadArticles = 0.0 + #expect(settings.maxUnreadArticlesInt == 0) + + settings.maxUnreadArticles = 1000.0 + #expect(settings.maxUnreadArticlesInt == 1000) + + settings.maxUnreadArticles = 0.1 + #expect(settings.maxUnreadArticlesInt == 0) + } +} diff --git a/readeckTests/Utils/HTMLImageEmbedderTests.swift b/readeckTests/Utils/HTMLImageEmbedderTests.swift new file mode 100644 index 0000000..e4360d8 --- /dev/null +++ b/readeckTests/Utils/HTMLImageEmbedderTests.swift @@ -0,0 +1,306 @@ +// +// HTMLImageEmbedderTests.swift +// readeckTests +// +// Created by Claude on 30.11.25. +// + +import Testing +import Foundation +import Kingfisher +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif +@testable import readeck + +@Suite("HTMLImageEmbedder Tests") +struct HTMLImageEmbedderTests { + + // MARK: - Test Data + + private let htmlWithImages = """ + + + Image 1 + + + + """ + + private let htmlWithoutImages = """ + + +

Just text, no images here.

+ + + """ + + private let htmlWithDataURI = """ + + + + + + + """ + + // MARK: - Helper Methods + + /// Creates a test image and caches it in Kingfisher for testing + private func cacheTestImage(url: URL) async { + // Create a simple 1x1 pixel red image for testing + #if os(iOS) + let size = CGSize(width: 1, height: 1) + UIGraphicsBeginImageContextWithOptions(size, false, 1.0) + UIColor.red.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + #elseif os(macOS) + let size = NSSize(width: 1, height: 1) + let image = NSImage(size: size) + image.lockFocus() + NSColor.red.setFill() + NSBezierPath.fill(NSRect(origin: .zero, size: size)) + image.unlockFocus() + #endif + + if let image = image { + // Store both in memory and on disk for testing + let options = KingfisherParsedOptionsInfo([ + .cacheOriginalImage, + .diskCacheExpiration(.never) + ]) + try? await ImageCache.default.store(image, forKey: url.cacheKey, options: options) + + // Small delay to ensure cache write completes + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + } + } + + /// Clears all cached images after tests + private func clearTestCache() async { + // Clear both memory and disk cache + await ImageCache.default.clearMemoryCache() + await ImageCache.default.clearDiskCache() + + // Small delay to ensure cache clear completes + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + } + + // MARK: - Basic Functionality Tests + + @Test("Embed Base64 images converts URLs to data URIs") + func testEmbedBase64ImagesConvertsURLs() async { + // Clear cache first to ensure clean state + await clearTestCache() + + let embedder = HTMLImageEmbedder() + + // Cache test images first + let url1 = URL(string: "https://example.com/image1.jpg")! + let url2 = URL(string: "https://example.com/image2.png")! + + await cacheTestImage(url: url1) + await cacheTestImage(url: url2) + + let result = await embedder.embedBase64Images(in: htmlWithImages) + + // Verify images were embedded as Base64 + #expect(result.contains("data:image/jpeg;base64,")) + #expect(!result.contains("https://example.com/image1.jpg")) + #expect(!result.contains("https://example.com/image2.png")) + + await clearTestCache() + } + + @Test("Embed Base64 images skips images not in cache") + func testEmbedBase64ImagesSkipsUncachedImages() async { + // Clear cache first to ensure clean state + await clearTestCache() + + let embedder = HTMLImageEmbedder() + + // Don't cache any images - all should be skipped + let result = await embedder.embedBase64Images(in: htmlWithImages) + + // Original URLs should remain unchanged + #expect(result.contains("https://example.com/image1.jpg")) + #expect(result.contains("https://example.com/image2.png")) + #expect(!result.contains("data:image/jpeg;base64,")) + } + + @Test("Embed Base64 images increases HTML size") + func testEmbedBase64ImagesIncreasesHTMLSize() async { + // Clear cache first to ensure clean state + await clearTestCache() + + let embedder = HTMLImageEmbedder() + + // Cache one test image + let url1 = URL(string: "https://example.com/image1.jpg")! + await cacheTestImage(url: url1) + + let originalSize = htmlWithImages.utf8.count + let result = await embedder.embedBase64Images(in: htmlWithImages) + let newSize = result.utf8.count + + // Base64 encoded images should make HTML larger + #expect(newSize > originalSize) + + await clearTestCache() + } + + @Test("Embed Base64 images uses JPEG format with quality 0.85") + func testEmbedBase64ImagesUsesJPEGFormat() async { + // Clear cache first to ensure clean state + await clearTestCache() + + let embedder = HTMLImageEmbedder() + + let url = URL(string: "https://example.com/image1.jpg")! + await cacheTestImage(url: url) + + let result = await embedder.embedBase64Images(in: htmlWithImages) + + // Verify data URI uses JPEG format + #expect(result.contains("data:image/jpeg;base64,")) + + await clearTestCache() + } + + // MARK: - Edge Case Tests + + @Test("Embed Base64 images handles empty HTML") + func testEmbedBase64ImagesHandlesEmptyHTML() async { + let embedder = HTMLImageEmbedder() + let emptyHTML = "" + + let result = await embedder.embedBase64Images(in: emptyHTML) + + #expect(result.isEmpty) + #expect(result == emptyHTML) + } + + @Test("Embed Base64 images handles HTML without images") + func testEmbedBase64ImagesHandlesHTMLWithoutImages() async { + let embedder = HTMLImageEmbedder() + + let result = await embedder.embedBase64Images(in: htmlWithoutImages) + + // Should return unchanged HTML + #expect(result == htmlWithoutImages) + #expect(!result.contains("data:image")) + } + + @Test("Embed Base64 images skips already embedded data URIs") + func testEmbedBase64ImagesSkipsDataURIs() async { + // Clear cache first to ensure clean state + await clearTestCache() + + let embedder = HTMLImageEmbedder() + + // Cache the non-data URI image + let url = URL(string: "https://example.com/new-image.jpg")! + await cacheTestImage(url: url) + + let result = await embedder.embedBase64Images(in: htmlWithDataURI) + + // Original data URI should remain + #expect(result.contains("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2w==")) + + // New image should be embedded + #expect(!result.contains("https://example.com/new-image.jpg")) + + await clearTestCache() + } + + @Test("Embed Base64 images processes multiple images correctly") + func testEmbedBase64ImagesProcessesMultipleImages() async { + // Clear cache first to ensure clean state + await clearTestCache() + + let embedder = HTMLImageEmbedder() + let htmlMultiple = """ + + + + """ + + // Cache all three images + for i in 1...3 { + let url = URL(string: "https://example.com/img\(i).jpg")! + await cacheTestImage(url: url) + } + + let result = await embedder.embedBase64Images(in: htmlMultiple) + + // All three should be embedded + let dataURICount = result.components(separatedBy: "data:image/jpeg;base64,").count - 1 + #expect(dataURICount == 3) + + // None of the original URLs should remain + #expect(!result.contains("https://example.com/img1.jpg")) + #expect(!result.contains("https://example.com/img2.jpg")) + #expect(!result.contains("https://example.com/img3.jpg")) + + await clearTestCache() + } + + // MARK: - Statistics & Logging Tests + + @Test("Embed Base64 images tracks success and failure counts") + func testEmbedBase64ImagesTracksStatistics() async { + // Clear cache first to ensure clean state + await clearTestCache() + + let embedder = HTMLImageEmbedder() + let htmlMixed = """ + + + """ + + // Cache only the first image + let cachedURL = URL(string: "https://cached.com/image.jpg")! + await cacheTestImage(url: cachedURL) + + let result = await embedder.embedBase64Images(in: htmlMixed) + + // First image should be embedded + #expect(result.contains("data:image/jpeg;base64,")) + #expect(!result.contains("https://cached.com/image.jpg")) + + // Second image should remain as URL + #expect(result.contains("https://not-cached.com/image.jpg")) + + await clearTestCache() + } + + @Test("Embed Base64 images handles invalid URLs gracefully") + func testEmbedBase64ImagesHandlesInvalidURLs() async { + // Clear cache first to ensure clean state + await clearTestCache() + + let embedder = HTMLImageEmbedder() + let htmlInvalid = """ + + + """ + + let url = URL(string: "https://valid.com/image.jpg")! + await cacheTestImage(url: url) + + let result = await embedder.embedBase64Images(in: htmlInvalid) + + // Invalid URL should remain unchanged + #expect(result.contains("not a valid url")) + + // Valid URL should be embedded + #expect(!result.contains("https://valid.com/image.jpg")) + #expect(result.contains("data:image/jpeg;base64,")) + + await clearTestCache() + } +} diff --git a/readeckTests/Utils/HTMLImageExtractorTests.swift b/readeckTests/Utils/HTMLImageExtractorTests.swift new file mode 100644 index 0000000..eb78cce --- /dev/null +++ b/readeckTests/Utils/HTMLImageExtractorTests.swift @@ -0,0 +1,194 @@ +// +// HTMLImageExtractorTests.swift +// readeckTests +// +// Created by Claude on 30.11.25. +// + +import Testing +import Foundation +@testable import readeck + +@Suite("HTMLImageExtractor Tests") +struct HTMLImageExtractorTests { + + // MARK: - Test Data + + private let htmlWithImages = """ + + + Image 1 + + + + + """ + + private let htmlWithMixedURLs = """ + + + + + + + + + """ + + private let htmlWithoutImages = """ + + +

This is just text content with no images.

+
Some more content
+ + + """ + + private let htmlEmpty = "" + + // MARK: - Basic Functionality Tests + + @Test("Extract finds all absolute image URLs from HTML") + func testExtractFindsAllImageURLs() { + let extractor = HTMLImageExtractor() + let imageURLs = extractor.extract(from: htmlWithImages) + + #expect(imageURLs.count == 3) + #expect(imageURLs.contains("https://example.com/image1.jpg")) + #expect(imageURLs.contains("https://example.com/image2.png")) + #expect(imageURLs.contains("https://example.com/image3.gif")) + } + + @Test("Extract only includes absolute URLs with http or https") + func testExtractOnlyIncludesAbsoluteURLs() { + let extractor = HTMLImageExtractor() + let imageURLs = extractor.extract(from: htmlWithMixedURLs) + + #expect(imageURLs.count == 2) + #expect(imageURLs.contains("https://absolute.com/img.jpg")) + #expect(imageURLs.contains("https://another.com/photo.png")) + + // Verify relative and data URIs are NOT included + #expect(!imageURLs.contains("/relative/path.jpg")) + #expect(!imageURLs.contains(where: { $0.hasPrefix("data:") })) + } + + @Test("Extract returns empty array when HTML has no images") + func testExtractReturnsEmptyArrayWhenNoImages() { + let extractor = HTMLImageExtractor() + let imageURLs = extractor.extract(from: htmlWithoutImages) + + #expect(imageURLs.isEmpty) + } + + // MARK: - Edge Case Tests + + @Test("Extract ignores relative URLs without http prefix") + func testExtractIgnoresRelativeURLs() { + let htmlWithRelative = """ + + + + + """ + + let extractor = HTMLImageExtractor() + let imageURLs = extractor.extract(from: htmlWithRelative) + + #expect(imageURLs.count == 1) + #expect(imageURLs.first == "https://valid.com/image.jpg") + } + + @Test("Extract handles empty HTML string") + func testExtractHandlesEmptyHTML() { + let extractor = HTMLImageExtractor() + let imageURLs = extractor.extract(from: htmlEmpty) + + #expect(imageURLs.isEmpty) + } + + @Test("Extract ignores data URI images") + func testExtractIgnoresDataURIs() { + let htmlWithDataURI = """ + + + + """ + + let extractor = HTMLImageExtractor() + let imageURLs = extractor.extract(from: htmlWithDataURI) + + #expect(imageURLs.count == 1) + #expect(imageURLs.first == "https://example.com/real-image.jpg") + + // Verify no data URIs are included + for url in imageURLs { + #expect(!url.hasPrefix("data:")) + } + } + + // MARK: - Hero/Thumbnail Tests + + @Test("Extract with hero image prepends it to array") + func testExtractWithHeroImagePrependsToArray() { + let extractor = HTMLImageExtractor() + let heroURL = "https://example.com/hero.jpg" + + let imageURLs = extractor.extract( + from: htmlWithImages, + heroImageURL: heroURL, + thumbnailURL: nil + ) + + #expect(imageURLs.count == 4) // 3 from HTML + 1 hero + #expect(imageURLs.first == heroURL) // Hero should be at position 0 + #expect(imageURLs.contains("https://example.com/image1.jpg")) + } + + @Test("Extract with thumbnail prepends it when no hero image") + func testExtractWithThumbnailPrependsWhenNoHero() { + let extractor = HTMLImageExtractor() + let thumbnailURL = "https://example.com/thumbnail.jpg" + + let imageURLs = extractor.extract( + from: htmlWithImages, + heroImageURL: nil, + thumbnailURL: thumbnailURL + ) + + #expect(imageURLs.count == 4) // 3 from HTML + 1 thumbnail + #expect(imageURLs.first == thumbnailURL) // Thumbnail should be at position 0 + } + + @Test("Extract prefers hero image over thumbnail when both provided") + func testExtractPrefersHeroOverThumbnail() { + let extractor = HTMLImageExtractor() + let heroURL = "https://example.com/hero.jpg" + let thumbnailURL = "https://example.com/thumbnail.jpg" + + let imageURLs = extractor.extract( + from: htmlWithImages, + heroImageURL: heroURL, + thumbnailURL: thumbnailURL + ) + + #expect(imageURLs.count == 4) // 3 from HTML + 1 hero (thumbnail ignored) + #expect(imageURLs.first == heroURL) // Hero takes precedence + #expect(!imageURLs.contains(thumbnailURL)) // Thumbnail should NOT be added + } + + @Test("Extract with hero and thumbnail but no HTML images") + func testExtractWithHeroAndNoHTMLImages() { + let extractor = HTMLImageExtractor() + let heroURL = "https://example.com/hero.jpg" + + let imageURLs = extractor.extract( + from: htmlWithoutImages, + heroImageURL: heroURL, + thumbnailURL: nil + ) + + #expect(imageURLs.count == 1) + #expect(imageURLs.first == heroURL) + } +} diff --git a/readeckTests/Utils/KingfisherImagePrefetcherTests.swift b/readeckTests/Utils/KingfisherImagePrefetcherTests.swift new file mode 100644 index 0000000..dc39559 --- /dev/null +++ b/readeckTests/Utils/KingfisherImagePrefetcherTests.swift @@ -0,0 +1,239 @@ +// +// KingfisherImagePrefetcherTests.swift +// readeckTests +// +// Created by Claude on 30.11.25. +// + +import Testing +import Foundation +import Kingfisher +@testable import readeck +import UIKit + +@Suite("KingfisherImagePrefetcher Tests") +struct KingfisherImagePrefetcherTests { + + // MARK: - Test Setup & Helpers + + /// Mock server URL for test images + private let testImageURL1 = URL(string: "https://via.placeholder.com/150/FF0000/FFFFFF?text=Test1")! + private let testImageURL2 = URL(string: "https://via.placeholder.com/150/00FF00/FFFFFF?text=Test2")! + private let testImageURL3 = URL(string: "https://via.placeholder.com/150/0000FF/FFFFFF?text=Test3")! + + /// Creates a simple test image for caching + private func createTestImage() -> KFCrossPlatformImage { + #if os(iOS) + let size = CGSize(width: 10, height: 10) + UIGraphicsBeginImageContextWithOptions(size, false, 1.0) + UIColor.blue.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + let image = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + return image + #elseif os(macOS) + let size = NSSize(width: 10, height: 10) + let image = NSImage(size: size) + image.lockFocus() + NSColor.blue.setFill() + NSBezierPath.fill(NSRect(origin: .zero, size: size)) + image.unlockFocus() + return image + #endif + } + + /// Clears Kingfisher cache after tests + private func clearCache() async { + await ImageCache.default.clearCache() + } + + /// Checks if an image is cached + private func isImageCached(forKey key: String) async -> Bool { + await withCheckedContinuation { continuation in + ImageCache.default.retrieveImage(forKey: key) { result in + switch result { + case .success(let cacheResult): + continuation.resume(returning: cacheResult.image != nil) + case .failure: + continuation.resume(returning: false) + } + } + } + } + + // MARK: - Prefetch Tests + + @Test("Prefetch images handles empty URL array") + func testPrefetchImagesHandlesEmptyArray() async { + let prefetcher = KingfisherImagePrefetcher() + let emptyURLs: [URL] = [] + + // Should complete without errors + await prefetcher.prefetchImages(urls: emptyURLs) + + // No assertions needed - just verify it doesn't crash + #expect(emptyURLs.isEmpty) + } + + @Test("Prefetch images uses never expiration for disk cache") + func testPrefetchImagesUsesNeverExpiration() async { + // This test verifies the configuration is set correctly + // The actual implementation uses .diskCacheExpiration(.never) + let prefetcher = KingfisherImagePrefetcher() + + // Pre-cache a test image to verify it persists + let testURL = URL(string: "https://example.com/test.jpg")! + let testImage = createTestImage() + + try? await ImageCache.default.store( + testImage, + forKey: testURL.cacheKey, + options: KingfisherParsedOptionsInfo([.diskCacheExpiration(.never)]) + ) + + let isCached = await isImageCached(forKey: testURL.cacheKey) + #expect(isCached == true) + + await clearCache() + } + + @Test("Verify prefetched images confirms cache status") + func testVerifyPrefetchedImagesConfirmsCacheStatus() async { + let prefetcher = KingfisherImagePrefetcher() + + // Manually cache some test images + let url1 = URL(string: "https://example.com/cached1.jpg")! + let url2 = URL(string: "https://example.com/cached2.jpg")! + let url3 = URL(string: "https://example.com/not-cached.jpg")! + + let testImage = createTestImage() + try? await ImageCache.default.store(testImage, forKey: url1.cacheKey) + try? await ImageCache.default.store(testImage, forKey: url2.cacheKey) + + // Verify the cached ones + await prefetcher.verifyPrefetchedImages([url1, url2, url3]) + + // Check that first two are cached + let isCached1 = await isImageCached(forKey: url1.cacheKey) + let isCached2 = await isImageCached(forKey: url2.cacheKey) + let isCached3 = await isImageCached(forKey: url3.cacheKey) + + #expect(isCached1 == true) + #expect(isCached2 == true) + #expect(isCached3 == false) + + await clearCache() + } + + // MARK: - Custom Cache Key Tests + + @Test("Cache image with custom key stores correctly") + func testCacheImageWithCustomKeyStoresCorrectly() async { + let prefetcher = KingfisherImagePrefetcher() + let customKey = "bookmark-123-hero" + + // Pre-cache a test image with URL key so it can be "downloaded" + let sourceURL = URL(string: "https://example.com/hero.jpg")! + let testImage = createTestImage() + try? await ImageCache.default.store(testImage, forKey: sourceURL.cacheKey) + + // Now use the prefetcher to cache with custom key + await prefetcher.cacheImageWithCustomKey(url: sourceURL, key: customKey) + + // Verify it's cached with custom key + let isCached = await isImageCached(forKey: customKey) + #expect(isCached == true) + + await clearCache() + } + + @Test("Cache image with custom key skips if already cached") + func testCacheImageWithCustomKeySkipsIfAlreadyCached() async { + let prefetcher = KingfisherImagePrefetcher() + let customKey = "bookmark-456-hero" + let sourceURL = URL(string: "https://example.com/hero2.jpg")! + + // Pre-cache with custom key + let testImage = createTestImage() + try? await ImageCache.default.store(testImage, forKey: customKey) + + // Call again - should skip (verify by checking it doesn't fail) + await prefetcher.cacheImageWithCustomKey(url: sourceURL, key: customKey) + + // Should still be cached + let isCached = await isImageCached(forKey: customKey) + #expect(isCached == true) + + await clearCache() + } + + // MARK: - Clear Cache Tests + + @Test("Clear cached images removes all specified URLs") + func testClearCachedImagesRemovesAllURLs() async { + let prefetcher = KingfisherImagePrefetcher() + + // Cache some test images + let url1 = URL(string: "https://example.com/clear1.jpg")! + let url2 = URL(string: "https://example.com/clear2.jpg")! + let testImage = createTestImage() + + try? await ImageCache.default.store(testImage, forKey: url1.cacheKey) + try? await ImageCache.default.store(testImage, forKey: url2.cacheKey) + + // Verify they are cached + var isCached1 = await isImageCached(forKey: url1.cacheKey) + var isCached2 = await isImageCached(forKey: url2.cacheKey) + #expect(isCached1 == true) + #expect(isCached2 == true) + + // Clear them + await prefetcher.clearCachedImages(urls: [url1, url2]) + + // Verify they are removed + isCached1 = await isImageCached(forKey: url1.cacheKey) + isCached2 = await isImageCached(forKey: url2.cacheKey) + #expect(isCached1 == false) + #expect(isCached2 == false) + } + + @Test("Clear cached images handles empty array") + func testClearCachedImagesHandlesEmptyArray() async { + let prefetcher = KingfisherImagePrefetcher() + let emptyURLs: [URL] = [] + + // Should complete without errors + await prefetcher.clearCachedImages(urls: emptyURLs) + + // No assertions needed - just verify it doesn't crash + #expect(emptyURLs.isEmpty) + } + + // MARK: - Integration Tests + + @Test("Prefetch and verify workflow") + func testPrefetchAndVerifyWorkflow() async { + let prefetcher = KingfisherImagePrefetcher() + + // Pre-populate cache with test images + let urls = [ + URL(string: "https://example.com/workflow1.jpg")!, + URL(string: "https://example.com/workflow2.jpg")! + ] + + let testImage = createTestImage() + for url in urls { + try? await ImageCache.default.store(testImage, forKey: url.cacheKey) + } + + // Verify they were cached + await prefetcher.verifyPrefetchedImages(urls) + + for url in urls { + let isCached = await isImageCached(forKey: url.cacheKey) + #expect(isCached == true) + } + + await clearCache() + } +}