//
// OfflineCacheRepositoryTests.swift
// readeckTests
//
// Created by Ilyas Hallak 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 = """
"""
// 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)
}
}