restructure
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled

This commit is contained in:
2026-03-30 16:39:18 -04:00
parent a8e07d52f0
commit c2e1622bd8
252 changed files with 4803 additions and 17165 deletions

View File

@@ -0,0 +1,395 @@
//
// DatabaseManagerTests.swift
// RSSuperTests
//
// Created on 3/29/26.
//
import XCTest
@testable import RSSuper
final class DatabaseManagerTests: XCTestCase {
private var databaseManager: DatabaseManager!
private var testSubscriptionId: String!
override func setUp() {
super.setUp()
databaseManager = DatabaseManager.shared
testSubscriptionId = UUID().uuidString
// Clean up any existing test data
try? databaseManager.deleteSubscription(id: testSubscriptionId)
}
override func tearDown() {
// Clean up test data
try? databaseManager.deleteSubscription(id: testSubscriptionId)
databaseManager = nil
super.tearDown()
}
// MARK: - Subscription CRUD Tests
func testCreateSubscription() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com/feed.xml",
title: "Test Subscription",
category: "Technology",
enabled: true,
fetchInterval: 3600
)
XCTAssertEqual(subscription.id, testSubscriptionId)
XCTAssertEqual(subscription.url, "https://example.com/feed.xml")
XCTAssertEqual(subscription.title, "Test Subscription")
XCTAssertEqual(subscription.category, "Technology")
XCTAssertTrue(subscription.enabled)
XCTAssertEqual(subscription.fetchInterval, 3600)
}
func testFetchSubscription() throws {
// Create first
_ = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com/feed.xml",
title: "Test Subscription"
)
// Fetch
let fetched = try databaseManager.fetchSubscription(id: testSubscriptionId)
XCTAssertNotNil(fetched)
XCTAssertEqual(fetched?.id, testSubscriptionId)
XCTAssertEqual(fetched?.title, "Test Subscription")
}
func testFetchSubscriptionNotFound() throws {
let fetched = try databaseManager.fetchSubscription(id: "non-existent-id")
XCTAssertNil(fetched)
}
func testFetchAllSubscriptions() throws {
let id1 = UUID().uuidString
let id2 = UUID().uuidString
try databaseManager.createSubscription(id: id1, url: "https://example1.com", title: "Sub 1")
try databaseManager.createSubscription(id: id2, url: "https://example2.com", title: "Sub 2")
let subscriptions = try databaseManager.fetchAllSubscriptions()
XCTAssertGreaterThanOrEqual(subscriptions.count, 2)
// Cleanup
try databaseManager.deleteSubscription(id: id1)
try databaseManager.deleteSubscription(id: id2)
}
func testFetchEnabledSubscriptions() throws {
let id1 = UUID().uuidString
let id2 = UUID().uuidString
try databaseManager.createSubscription(id: id1, url: "https://example1.com", title: "Sub 1", enabled: true)
try databaseManager.createSubscription(id: id2, url: "https://example2.com", title: "Sub 2", enabled: false)
let subscriptions = try databaseManager.fetchEnabledSubscriptions()
XCTAssertTrue(subscriptions.allSatisfy { $0.enabled })
// Cleanup
try databaseManager.deleteSubscription(id: id1)
try databaseManager.deleteSubscription(id: id2)
}
func testUpdateSubscription() throws {
_ = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Original Title"
)
let updated = try databaseManager.updateSubscription(
try databaseManager.fetchSubscription(id: testSubscriptionId)!,
title: "Updated Title",
enabled: false
)
XCTAssertEqual(updated.title, "Updated Title")
XCTAssertFalse(updated.enabled)
}
func testDeleteSubscription() throws {
_ = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "To Delete"
)
try databaseManager.deleteSubscription(id: testSubscriptionId)
let fetched = try databaseManager.fetchSubscription(id: testSubscriptionId)
XCTAssertNil(fetched)
}
// MARK: - FeedItem CRUD Tests
func testCreateFeedItem() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
let item = FeedItem(
id: UUID().uuidString,
title: "Test Article",
link: "https://example.com/article",
description: "Article description",
content: "Full article content",
author: "John Doe",
published: Date(),
subscriptionId: subscription.id,
subscriptionTitle: subscription.title
)
let created = try databaseManager.createFeedItem(item)
XCTAssertEqual(created.id, item.id)
XCTAssertEqual(created.title, "Test Article")
XCTAssertEqual(created.subscriptionId, testSubscriptionId)
}
func testFetchFeedItem() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
let itemId = UUID().uuidString
let item = FeedItem(
id: itemId,
title: "To Fetch",
subscriptionId: subscription.id
)
_ = try databaseManager.createFeedItem(item)
let fetched = try databaseManager.fetchFeedItem(id: itemId)
XCTAssertNotNil(fetched)
XCTAssertEqual(fetched?.id, itemId)
}
func testFetchFeedItemsForSubscription() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
for i in 1...3 {
let item = FeedItem(
id: UUID().uuidString,
title: "Article \(i)",
subscriptionId: subscription.id
)
_ = try databaseManager.createFeedItem(item)
}
let items = try databaseManager.fetchFeedItems(for: testSubscriptionId)
XCTAssertEqual(items.count, 3)
}
func testFetchUnreadFeedItems() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
let readItem = FeedItem(id: UUID().uuidString, title: "Read", subscriptionId: subscription.id, read: true)
let unreadItem = FeedItem(id: UUID().uuidString, title: "Unread", subscriptionId: subscription.id, read: false)
_ = try databaseManager.createFeedItem(readItem)
_ = try databaseManager.createFeedItem(unreadItem)
let unreadItems = try databaseManager.fetchUnreadFeedItems()
XCTAssertTrue(unreadItems.allSatisfy { !$0.read })
}
func testMarkAsRead() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
let item1 = FeedItem(id: UUID().uuidString, title: "Item 1", subscriptionId: subscription.id, read: false)
let item2 = FeedItem(id: UUID().uuidString, title: "Item 2", subscriptionId: subscription.id, read: false)
_ = try databaseManager.createFeedItem(item1)
_ = try databaseManager.createFeedItem(item2)
try databaseManager.markAsRead(ids: [item1.id, item2.id])
let fetched1 = try databaseManager.fetchFeedItem(id: item1.id)
let fetched2 = try databaseManager.fetchFeedItem(id: item2.id)
XCTAssertTrue(fetched1?.read ?? false)
XCTAssertTrue(fetched2?.read ?? false)
}
func testDeleteFeedItem() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
let item = FeedItem(id: UUID().uuidString, title: "To Delete", subscriptionId: subscription.id)
_ = try databaseManager.createFeedItem(item)
try databaseManager.deleteFeedItem(id: item.id)
let fetched = try databaseManager.fetchFeedItem(id: item.id)
XCTAssertNil(fetched)
}
// MARK: - SearchHistory Tests
func testAddToSearchHistory() throws {
let item = try databaseManager.addToSearchHistory(query: "test query")
XCTAssertEqual(item.query, "test query")
XCTAssertNotNil(item.id)
}
func testFetchSearchHistory() throws {
try databaseManager.addToSearchHistory(query: "Query 1")
try databaseManager.addToSearchHistory(query: "Query 2")
let history = try databaseManager.fetchSearchHistory()
XCTAssertGreaterThanOrEqual(history.count, 2)
}
func testClearSearchHistory() throws {
try databaseManager.addToSearchHistory(query: "To Clear")
try databaseManager.clearSearchHistory()
let history = try databaseManager.fetchSearchHistory()
XCTAssertTrue(history.isEmpty)
}
// MARK: - FTS Search Tests
func testFullTextSearch() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
let searchableItem = FeedItem(
id: UUID().uuidString,
title: "Unique Title for Search Test",
description: "This has special content",
subscriptionId: subscription.id
)
let nonMatchingItem = FeedItem(
id: UUID().uuidString,
title: "Completely Different",
subscriptionId: subscription.id
)
_ = try databaseManager.createFeedItem(searchableItem)
_ = try databaseManager.createFeedItem(nonMatchingItem)
let results = try databaseManager.fullTextSearch(query: "Unique")
XCTAssertTrue(results.contains { $0.id == searchableItem.id || $0.title.contains("Unique") })
XCTAssertFalse(results.contains { $0.id == nonMatchingItem.id })
}
func testAdvancedSearch() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
let item1 = FeedItem(
id: UUID().uuidString,
title: "Searchable Title",
author: "Test Author",
subscriptionId: subscription.id
)
let item2 = FeedItem(
id: UUID().uuidString,
title: "Different Title",
author: "Other Author",
subscriptionId: subscription.id
)
_ = try databaseManager.createFeedItem(item1)
_ = try databaseManager.createFeedItem(item2)
let results = try databaseManager.advancedSearch(title: "Searchable", author: "Test")
XCTAssertTrue(results.contains { $0.id == item1.id })
XCTAssertFalse(results.contains { $0.id == item2.id })
}
// MARK: - Batch Operations Tests
func testMarkAllAsRead() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
_ = try databaseManager.createFeedItem(FeedItem(id: UUID().uuidString, title: "Item 1", subscriptionId: subscription.id, read: false))
_ = try databaseManager.createFeedItem(FeedItem(id: UUID().uuidString, title: "Item 2", subscriptionId: subscription.id, read: false))
try databaseManager.markAllAsRead(for: testSubscriptionId)
let items = try databaseManager.fetchFeedItems(for: testSubscriptionId)
XCTAssertTrue(items.allSatisfy { $0.read })
}
func testCleanupOldItems() throws {
let subscription = try databaseManager.createSubscription(
id: testSubscriptionId,
url: "https://example.com",
title: "Test Sub"
)
let oldItem = FeedItem(
id: UUID().uuidString,
title: "Old Item",
published: Calendar.current.date(byAdding: .day, value: -30, to: Date()),
subscriptionId: subscription.id
)
let newItem = FeedItem(
id: UUID().uuidString,
title: "New Item",
published: Date(),
subscriptionId: subscription.id
)
_ = try databaseManager.createFeedItem(oldItem)
_ = try databaseManager.createFeedItem(newItem)
let deletedCount = try databaseManager.cleanupOldItems(olderThan: 7, for: testSubscriptionId)
XCTAssertEqual(deletedCount, 1)
let remainingItems = try databaseManager.fetchFeedItems(for: testSubscriptionId)
XCTAssertEqual(remainingItems.count, 1)
XCTAssertEqual(remainingItems.first?.id, newItem.id)
}
}

View File

@@ -0,0 +1,56 @@
//
// DateExtensionsTests.swift
// RSSuperTests
//
// Created by Mike Freno on 3/29/26.
//
import XCTest
@testable import RSSuper
final class DateExtensionsTests: XCTestCase {
func testMillisecondsSince1970() {
let date = Date(timeIntervalSince1970: 1609459200)
XCTAssertEqual(date.millisecondsSince1970, 1609459200000)
}
func testInitFromMilliseconds() {
let date = Date(millisecondsSince1970: 1609459200000)!
XCTAssertEqual(date.timeIntervalSince1970, 1609459200)
}
func testSecondsSince1970() {
let date = Date(timeIntervalSince1970: 1609459200)
XCTAssertEqual(date.secondsSince1970, 1609459200)
}
func testInitFromSeconds() {
let date = Date(secondsSince1970: 1609459200)!
XCTAssertEqual(date.timeIntervalSince1970, 1609459200)
}
func testISO8601String() {
let date = Date(timeIntervalSince1970: 1609459200)
let isoString = date.iso8601String
// Verify it's a valid ISO8601 string
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
XCTAssertNotNil(formatter.date(from: isoString))
}
func testInitFromISO8601String() {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoString = formatter.string(from: Date(timeIntervalSince1970: 1609459200))
let date = Date(iso8601String: isoString)!
XCTAssertEqual(date.timeIntervalSince1970, 1609459200, accuracy: 1.0)
}
func testInvalidISO8601String() {
let date = Date(iso8601String: "not-a-date")
XCTAssertNil(date)
}
}

View File

@@ -0,0 +1,217 @@
import XCTest
import os.signpost
@testable import RSSuper
final class FeedFetcherTests: XCTestCase {
private var fetcher: FeedFetcher!
private var session: MockURLSession!
override func setUp() {
super.setUp()
session = MockURLSession()
fetcher = FeedFetcher(session: session)
}
override func tearDown() {
fetcher = nil
session = nil
super.tearDown()
}
func testFetchFeedSuccess() async throws {
let url = URL(string: "https://example.com/feed.xml")!
let feedData = Data(rssSample.utf8)
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!)
let result = try await fetcher.fetchFeed(url: url)
XCTAssertEqual(result.feedData, feedData)
XCTAssertEqual(result.url, url)
XCTAssertFalse(result.cached)
}
func testFetchFeedWithAuthentication() async throws {
let url = URL(string: "https://example.com/feed.xml")!
let feedData = Data(rssSample.utf8)
let credentials = HTTPAuthCredentials(username: "user", password: "pass")
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!)
let result = try await fetcher.fetchFeed(url: url, credentials: credentials)
XCTAssertEqual(result.feedData, feedData)
let authHeader = session.lastAuthHeader
XCTAssertTrue(authHeader?.starts(with: "Basic ") ?? false)
}
func testFetchFeedTimeoutError() async throws {
let url = URL(string: "https://example.com/feed.xml")!
session.error = URLError(.timedOut)
do {
_ = try await fetcher.fetchFeed(url: url)
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(true)
}
}
func testFetchFeed404Error() async throws {
let url = URL(string: "https://example.com/feed.xml")!
session.response = (Data(), HTTPURLResponse(url: url, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: nil)!)
do {
_ = try await fetcher.fetchFeed(url: url)
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(true)
}
}
func testFetchFeedAuthenticationError() async throws {
let url = URL(string: "https://example.com/feed.xml")!
session.response = (Data(), HTTPURLResponse(url: url, statusCode: 401, httpVersion: "HTTP/1.1", headerFields: nil)!)
do {
_ = try await fetcher.fetchFeed(url: url)
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(true)
}
}
func testFetchFeedRetriesOnTimeout() async throws {
let url = URL(string: "https://example.com/feed.xml")!
let feedData = Data(rssSample.utf8)
var callCount = 0
session.onRequest = { [unowned self] in
callCount += 1
if callCount < 3 {
self.session.error = URLError(.timedOut)
}
}
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!)
let result = try await fetcher.fetchFeed(url: url)
XCTAssertEqual(result.feedData, feedData)
XCTAssertEqual(callCount, 3)
}
func testFetchFeedCaching() async throws {
let url = URL(string: "https://example.com/feed.xml")!
let feedData = Data(rssSample.utf8)
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!)
let result1 = try await fetcher.fetchFeed(url: url)
XCTAssertFalse(result1.cached)
let result2 = try await fetcher.fetchFeed(url: url)
XCTAssertTrue(result2.cached)
XCTAssertEqual(result1.feedData, result2.feedData)
}
func testFetchFeedRespectsCacheControlNoStore() async throws {
let url = URL(string: "https://example.com/feed.xml")!
let feedData = Data(rssSample.utf8)
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Cache-Control": "no-store"])!)
let result1 = try await fetcher.fetchFeed(url: url)
XCTAssertFalse(result1.cached)
let result2 = try await fetcher.fetchFeed(url: url)
XCTAssertFalse(result2.cached)
}
func testHTTPAuthAuthorizationHeader() {
let credentials = HTTPAuthCredentials(username: "user", password: "pass")
let authHeader = credentials.authorizationHeader()
XCTAssertEqual(authHeader, "Basic dXNlcjpwYXNz")
}
func testHTTPAuthAuthorizationHeaderWithSpecialCharacters() {
let credentials = HTTPAuthCredentials(username: "user@domain", password: "p@ss:w0rd")
let authHeader = credentials.authorizationHeader()
let expectedData = "user@domain:p@ss:w0rd".data(using: .utf8)!
let expectedBase64 = expectedData.base64EncodedString()
XCTAssertEqual(authHeader, "Basic \(expectedBase64)")
}
}
private let rssSample = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>https://example.com</link>
<description>A test feed</description>
<item>
<title>Test Item</title>
<link>https://example.com/item1</link>
<guid>item-1</guid>
<description>Test item description</description>
</item>
</channel>
</rss>
"""
final class MockURLSession: URLSession {
var response: (Data, URLResponse)?
var error: Error?
var onRequest: (() -> Void)?
var lastAuthHeader: String?
private var requestCounter = 0
override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
requestCounter += 1
if let auth = request.allHTTPHeaderFields?["Authorization"] {
lastAuthHeader = auth
}
onRequest?()
if let error {
completionHandler(nil, nil, error)
return MockURLSessionDataTask(error: error)
}
if let (data, response) = response {
completionHandler(data, response, nil)
return MockURLSessionDataTask()
}
completionHandler(nil, nil, nil)
return MockURLSessionDataTask()
}
func reset() {
requestCounter = 0
lastAuthHeader = nil
}
}
final class MockURLSessionDataTask: URLSessionDataTask {
private let mockError: Error?
init(error: Error? = nil) {
self.mockError = error
super.init()
}
override func resume() {
// No-op for testing
}
}

View File

@@ -0,0 +1,148 @@
//
// FeedItemTests.swift
// RSSuperTests
//
// Created by Mike Freno on 3/29/26.
//
import XCTest
@testable import RSSuper
final class FeedItemTests: XCTestCase {
func testFeedItemEncodingDecoding() throws {
let item = FeedItem(
id: "550e8400-e29b-41d4-a716-446655440000",
title: "Test Article",
link: "https://example.com/article",
description: "A test description",
content: "Full content here",
author: "John Doe",
published: Date(millisecondsSince1970: 1609459200000),
categories: ["tech", "swift"],
enclosure: Enclosure(url: "https://example.com/audio.mp3", type: "audio/mpeg", length: 1234567),
guid: "guid-123",
subscriptionId: "sub-123",
subscriptionTitle: "Test Feed"
)
let data = try JSONEncoder().encode(item)
let decoded = try JSONDecoder().decode(FeedItem.self, from: data)
XCTAssertEqual(decoded.id, item.id)
XCTAssertEqual(decoded.title, item.title)
XCTAssertEqual(decoded.link, item.link)
XCTAssertEqual(decoded.description, item.description)
XCTAssertEqual(decoded.content, item.content)
XCTAssertEqual(decoded.author, item.author)
XCTAssertEqual(decoded.published, item.published)
XCTAssertEqual(decoded.categories, item.categories)
XCTAssertEqual(decoded.guid, item.guid)
XCTAssertEqual(decoded.subscriptionId, item.subscriptionId)
XCTAssertEqual(decoded.subscriptionTitle, item.subscriptionTitle)
}
func testFeedItemOptionalProperties() throws {
let item = FeedItem(
id: "test-id",
title: "Minimal Item",
subscriptionId: "sub-id"
)
XCTAssertNil(item.link)
XCTAssertNil(item.description)
XCTAssertNil(item.content)
XCTAssertNil(item.author)
XCTAssertNil(item.published)
XCTAssertNil(item.categories)
XCTAssertNil(item.enclosure)
XCTAssertNil(item.guid)
XCTAssertNil(item.subscriptionTitle)
let data = try JSONEncoder().encode(item)
let decoded = try JSONDecoder().decode(FeedItem.self, from: data)
XCTAssertEqual(decoded.id, item.id)
XCTAssertEqual(decoded.title, item.title)
}
func testEnclosureEncodingDecoding() throws {
let enclosure = Enclosure(
url: "https://example.com/podcast.mp3",
type: "audio/mpeg",
length: 98765432
)
let data = try JSONEncoder().encode(enclosure)
let decoded = try JSONDecoder().decode(Enclosure.self, from: data)
XCTAssertEqual(decoded.url, enclosure.url)
XCTAssertEqual(decoded.type, enclosure.type)
XCTAssertEqual(decoded.length, enclosure.length)
}
func testEnclosureWithoutLength() throws {
let enclosure = Enclosure(
url: "https://example.com/video.mp4",
type: "video/mp4"
)
let data = try JSONEncoder().encode(enclosure)
let decoded = try JSONDecoder().decode(Enclosure.self, from: data)
XCTAssertEqual(decoded.url, enclosure.url)
XCTAssertEqual(decoded.type, enclosure.type)
XCTAssertNil(decoded.length)
}
func testFeedItemEquality() {
let item1 = FeedItem(
id: "same-id",
title: "Same Title",
subscriptionId: "sub-id"
)
let item2 = FeedItem(
id: "same-id",
title: "Same Title",
subscriptionId: "sub-id"
)
let item3 = FeedItem(
id: "different-id",
title: "Different Title",
subscriptionId: "sub-id"
)
XCTAssertEqual(item1, item2)
XCTAssertNotEqual(item1, item3)
}
func testContentTypeDetection() {
let audioEnclosure = Enclosure(url: "test.mp3", type: "audio/mpeg")
XCTAssertEqual(audioEnclosure.mimeType, .audio)
let videoEnclosure = Enclosure(url: "test.mp4", type: "video/mp4")
XCTAssertEqual(videoEnclosure.mimeType, .video)
let articleEnclosure = Enclosure(url: "test.pdf", type: "application/pdf")
XCTAssertEqual(articleEnclosure.mimeType, .article)
}
func testFeedItemDebugDescription() {
let item = FeedItem(
id: "test-id",
title: "Debug Test",
author: "Test Author",
published: Date(millisecondsSince1970: 1609459200000),
subscriptionId: "sub-id",
subscriptionTitle: "My Feed"
)
let debugDesc = item.debugDescription
XCTAssertTrue(debugDesc.contains("test-id"))
XCTAssertTrue(debugDesc.contains("Debug Test"))
XCTAssertTrue(debugDesc.contains("Test Author"))
XCTAssertTrue(debugDesc.contains("My Feed"))
}
}

View File

@@ -0,0 +1,211 @@
import XCTest
@testable import RSSuper
final class FeedParserTests: XCTestCase {
func testParsesRSS20Feed() throws {
let parser = FeedParser()
let result = try parser.parse(
data: Data(rssSample.utf8),
sourceURL: "https://example.com/rss.xml"
)
XCTAssertEqual(result.feedType, .rss)
XCTAssertEqual(result.feed.title, "Example Podcast")
XCTAssertEqual(result.feed.subtitle, "Weekly iOS and Swift updates")
XCTAssertEqual(result.feed.items.count, 2)
XCTAssertEqual(result.feed.ttl, 60)
let firstItem = try XCTUnwrap(result.feed.items.first)
XCTAssertEqual(firstItem.title, "Episode 1")
XCTAssertEqual(firstItem.author, "Host Name")
XCTAssertEqual(firstItem.guid, "episode-1")
XCTAssertEqual(firstItem.categories, ["Swift"])
XCTAssertEqual(firstItem.enclosure?.url, "https://example.com/audio/ep1.mp3")
XCTAssertEqual(firstItem.enclosure?.type, "audio/mpeg")
XCTAssertEqual(firstItem.enclosure?.length, 12345)
XCTAssertEqual(firstItem.content, "<p>Full content for episode 1.</p>")
}
func testParsesAtom10Feed() throws {
let parser = FeedParser()
let result = try parser.parse(
data: Data(atomSample.utf8),
sourceURL: "https://example.com/atom.xml"
)
XCTAssertEqual(result.feedType, .atom)
XCTAssertEqual(result.feed.title, "Example Atom Feed")
XCTAssertEqual(result.feed.subtitle, "Recent engineering posts")
XCTAssertEqual(result.feed.link, "https://example.com")
XCTAssertEqual(result.feed.items.count, 2)
let firstItem = try XCTUnwrap(result.feed.items.first)
XCTAssertEqual(firstItem.guid, "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a")
XCTAssertEqual(firstItem.link, "https://example.com/posts/1")
XCTAssertEqual(firstItem.author, "Jane Author")
XCTAssertEqual(firstItem.enclosure?.url, "https://example.com/audio/post1.mp3")
XCTAssertEqual(firstItem.enclosure?.type, "audio/mpeg")
XCTAssertEqual(firstItem.enclosure?.length, 2048)
}
func testHandlesITunesNamespace() throws {
let parser = FeedParser()
let result = try parser.parse(
data: Data(rssWithITunesSample.utf8),
sourceURL: "https://example.com/itunes.xml"
)
XCTAssertEqual(result.feed.subtitle, "Podcast subtitle")
XCTAssertEqual(result.feed.description, "Feed-level summary")
let item = try XCTUnwrap(result.feed.items.first)
XCTAssertEqual(item.author, "Podcast Author")
XCTAssertEqual(item.description, "Item-level summary")
}
func testThrowsForMalformedXML() {
let parser = FeedParser()
XCTAssertThrowsError(
try parser.parse(
data: Data("<rss><channel><title>Broken".utf8),
sourceURL: "https://example.com/broken.xml"
)
) { error in
XCTAssertEqual(error as? FeedParsingError, .malformedXML)
}
}
func testParsesRealWorldStyleFeeds() throws {
let parser = FeedParser()
let rssResult = try parser.parse(
data: Data(realWorldRSSSample.utf8),
sourceURL: "https://feeds.example.com/news.xml"
)
XCTAssertEqual(rssResult.feedType, .rss)
XCTAssertGreaterThan(rssResult.feed.items.count, 0)
let atomResult = try parser.parse(
data: Data(realWorldAtomSample.utf8),
sourceURL: "https://feeds.example.com/engineering.xml"
)
XCTAssertEqual(atomResult.feedType, .atom)
XCTAssertGreaterThan(atomResult.feed.items.count, 0)
}
}
private let rssSample = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>Example Podcast</title>
<link>https://example.com</link>
<description>A sample RSS feed</description>
<language>en-us</language>
<lastBuildDate>Mon, 30 Mar 2026 10:00:00 +0000</lastBuildDate>
<generator>RSSuper Test Suite</generator>
<ttl>60</ttl>
<itunes:subtitle>Weekly iOS and Swift updates</itunes:subtitle>
<item>
<title>Episode 1</title>
<link>https://example.com/episodes/1</link>
<guid>episode-1</guid>
<pubDate>Mon, 30 Mar 2026 09:00:00 +0000</pubDate>
<category>Swift</category>
<description>Episode 1 summary</description>
<content:encoded><![CDATA[<p>Full content for episode 1.</p>]]></content:encoded>
<itunes:author>Host Name</itunes:author>
<enclosure url="https://example.com/audio/ep1.mp3" type="audio/mpeg" length="12345" />
</item>
<item>
<title>Episode 2</title>
<link>https://example.com/episodes/2</link>
<guid>episode-2</guid>
<pubDate>Mon, 30 Mar 2026 08:00:00 +0000</pubDate>
<description>Episode 2 summary</description>
</item>
</channel>
</rss>
"""
private let atomSample = """
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Atom Feed</title>
<subtitle>Recent engineering posts</subtitle>
<link href="https://example.com" rel="alternate" />
<updated>2026-03-30T10:00:00Z</updated>
<generator>Atom Test Generator</generator>
<entry>
<title>Post One</title>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2026-03-30T09:00:00Z</updated>
<published>2026-03-30T08:59:00Z</published>
<summary>First post summary</summary>
<content type="html">&lt;p&gt;First post full content&lt;/p&gt;</content>
<author><name>Jane Author</name></author>
<link href="https://example.com/posts/1" rel="alternate" />
<link href="https://example.com/audio/post1.mp3" rel="enclosure" type="audio/mpeg" length="2048" />
</entry>
<entry>
<title>Post Two</title>
<id>urn:uuid:7a9b2f0d-65b2-44a7-a2ad-d3c3ff7dd003</id>
<updated>2026-03-30T08:00:00Z</updated>
<summary>Second post summary</summary>
<link href="https://example.com/posts/2" rel="alternate" />
</entry>
</feed>
"""
private let rssWithITunesSample = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>iTunes Feed</title>
<itunes:subtitle>Podcast subtitle</itunes:subtitle>
<itunes:summary>Feed-level summary</itunes:summary>
<item>
<title>Episode</title>
<itunes:author>Podcast Author</itunes:author>
<itunes:summary>Item-level summary</itunes:summary>
</item>
</channel>
</rss>
"""
private let realWorldRSSSample = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Daily Tech News</title>
<link>https://news.example.com</link>
<description>Latest updates from the tech world</description>
<lastBuildDate>Mon, 30 Mar 2026 12:00:00 +0000</lastBuildDate>
<item>
<title>Apple ships new SDK tools</title>
<link>https://news.example.com/apple-sdk-tools</link>
<guid>news-1</guid>
<pubDate>Mon, 30 Mar 2026 11:00:00 +0000</pubDate>
<description>Tooling improvements for mobile developers.</description>
</item>
</channel>
</rss>
"""
private let realWorldAtomSample = """
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Engineering Blog</title>
<updated>2026-03-30T12:00:00Z</updated>
<link rel="alternate" href="https://engineering.example.com" />
<entry>
<title>Improving app startup performance</title>
<id>tag:engineering.example.com,2026:post-1</id>
<updated>2026-03-29T16:00:00Z</updated>
<published>2026-03-29T15:00:00Z</published>
<summary>How we reduced cold-start by 25%.</summary>
<content>Detailed analysis of startup bottlenecks and fixes.</content>
<author><name>Engineering Team</name></author>
<link rel="alternate" href="https://engineering.example.com/posts/startup-performance" />
</entry>
</feed>
"""

View File

@@ -0,0 +1,116 @@
//
// FeedSubscriptionTests.swift
// RSSuperTests
//
// Created by Mike Freno on 3/29/26.
//
import XCTest
@testable import RSSuper
final class FeedSubscriptionTests: XCTestCase {
func testFeedSubscriptionEncodingDecoding() throws {
let subscription = FeedSubscription(
id: "sub-123",
url: "https://example.com/feed.xml",
title: "Example Feed",
category: "Tech",
enabled: true,
fetchInterval: 120,
createdAt: Date(timeIntervalSince1970: 1609459200),
updatedAt: Date(timeIntervalSince1970: 1609545600),
lastFetchedAt: Date(timeIntervalSince1970: 1609632000),
nextFetchAt: Date(timeIntervalSince1970: 1609718400),
error: nil,
httpAuth: HttpAuth(username: "user", password: "pass")
)
let data = try JSONEncoder().encode(subscription)
let decoded = try JSONDecoder().decode(FeedSubscription.self, from: data)
XCTAssertEqual(decoded.id, subscription.id)
XCTAssertEqual(decoded.url, subscription.url)
XCTAssertEqual(decoded.title, subscription.title)
XCTAssertEqual(decoded.category, subscription.category)
XCTAssertEqual(decoded.enabled, subscription.enabled)
XCTAssertEqual(decoded.fetchInterval, subscription.fetchInterval)
XCTAssertEqual(decoded.createdAt, subscription.createdAt)
XCTAssertEqual(decoded.updatedAt, subscription.updatedAt)
XCTAssertEqual(decoded.lastFetchedAt, subscription.lastFetchedAt)
XCTAssertEqual(decoded.nextFetchAt, subscription.nextFetchAt)
XCTAssertEqual(decoded.httpAuth?.username, subscription.httpAuth?.username)
XCTAssertEqual(decoded.httpAuth?.password, subscription.httpAuth?.password)
}
func testFeedSubscriptionOptionalProperties() throws {
let subscription = FeedSubscription(
id: "minimal-sub",
url: "https://example.com/minimal.xml",
title: "Minimal Feed"
)
XCTAssertNil(subscription.category)
XCTAssertNil(subscription.lastFetchedAt)
XCTAssertNil(subscription.nextFetchAt)
XCTAssertNil(subscription.error)
XCTAssertNil(subscription.httpAuth)
let data = try JSONEncoder().encode(subscription)
let decoded = try JSONDecoder().decode(FeedSubscription.self, from: data)
XCTAssertEqual(decoded.id, subscription.id)
XCTAssertEqual(decoded.enabled, true)
XCTAssertEqual(decoded.fetchInterval, 60)
}
func testFeedSubscriptionEquality() {
let sub1 = FeedSubscription(
id: "same-id",
url: "https://example.com/feed.xml",
title: "Same Title"
)
let sub2 = FeedSubscription(
id: "same-id",
url: "https://example.com/feed.xml",
title: "Same Title"
)
let sub3 = FeedSubscription(
id: "different-id",
url: "https://example.com/other.xml",
title: "Different Title"
)
XCTAssertEqual(sub1, sub2)
XCTAssertNotEqual(sub1, sub3)
}
func testHttpAuthEncodingDecoding() throws {
let auth = HttpAuth(username: "testuser", password: "testpass")
let data = try JSONEncoder().encode(auth)
let decoded = try JSONDecoder().decode(HttpAuth.self, from: data)
XCTAssertEqual(decoded.username, auth.username)
XCTAssertEqual(decoded.password, auth.password)
}
func testFeedSubscriptionDebugDescription() {
let subscription = FeedSubscription(
id: "debug-id",
url: "https://example.com/debug.xml",
title: "Debug Feed",
enabled: false,
fetchInterval: 30,
error: "Connection timeout"
)
let debugDesc = subscription.debugDescription
XCTAssertTrue(debugDesc.contains("debug-id"))
XCTAssertTrue(debugDesc.contains("Debug Feed"))
XCTAssertTrue(debugDesc.contains("30min"))
XCTAssertTrue(debugDesc.contains("Connection timeout"))
}
}

View File

@@ -0,0 +1,102 @@
//
// NotificationPreferencesTests.swift
// RSSuperTests
//
// Created by Mike Freno on 3/29/26.
//
import XCTest
@testable import RSSuper
final class NotificationPreferencesTests: XCTestCase {
func testNotificationPreferencesEncodingDecoding() throws {
let prefs = NotificationPreferences(
newArticles: true,
episodeReleases: false,
customAlerts: true,
badgeCount: true,
sound: false,
vibration: true
)
let data = try JSONEncoder().encode(prefs)
let decoded = try JSONDecoder().decode(NotificationPreferences.self, from: data)
XCTAssertEqual(decoded.newArticles, prefs.newArticles)
XCTAssertEqual(decoded.episodeReleases, prefs.episodeReleases)
XCTAssertEqual(decoded.customAlerts, prefs.customAlerts)
XCTAssertEqual(decoded.badgeCount, prefs.badgeCount)
XCTAssertEqual(decoded.sound, prefs.sound)
XCTAssertEqual(decoded.vibration, prefs.vibration)
}
func testNotificationPreferencesDefaults() {
let prefs = NotificationPreferences()
XCTAssertEqual(prefs.newArticles, true)
XCTAssertEqual(prefs.episodeReleases, true)
XCTAssertEqual(prefs.customAlerts, false)
XCTAssertEqual(prefs.badgeCount, true)
XCTAssertEqual(prefs.sound, true)
XCTAssertEqual(prefs.vibration, true)
}
func testNotificationPreferencesAllEnabled() {
let allEnabled = NotificationPreferences(
newArticles: true,
episodeReleases: true,
customAlerts: true,
badgeCount: true,
sound: true,
vibration: true
)
XCTAssertTrue(allEnabled.allEnabled)
}
func testNotificationPreferencesAnyEnabled() {
let noneEnabled = NotificationPreferences(
newArticles: false,
episodeReleases: false,
customAlerts: false,
badgeCount: false,
sound: false,
vibration: false
)
XCTAssertFalse(noneEnabled.anyEnabled)
let someEnabled = NotificationPreferences(
newArticles: true,
episodeReleases: false,
customAlerts: false,
badgeCount: false,
sound: false,
vibration: false
)
XCTAssertTrue(someEnabled.anyEnabled)
}
func testNotificationPreferencesEquality() {
let prefs1 = NotificationPreferences(newArticles: true, episodeReleases: false)
let prefs2 = NotificationPreferences(newArticles: true, episodeReleases: false)
let prefs3 = NotificationPreferences(newArticles: false, episodeReleases: true)
XCTAssertEqual(prefs1, prefs2)
XCTAssertNotEqual(prefs1, prefs3)
}
func testNotificationPreferencesDebugDescription() {
let prefs = NotificationPreferences(
newArticles: true,
episodeReleases: false,
customAlerts: true
)
let debugDesc = prefs.debugDescription
XCTAssertTrue(debugDesc.contains("true"))
XCTAssertTrue(debugDesc.contains("false"))
}
}

View File

@@ -0,0 +1,18 @@
//
// RSSuperTests.swift
// RSSuperTests
//
// Created by Mike Freno on 3/29/26.
//
import XCTest
@testable import RSSuper
final class RSSuperTests: XCTestCase {
func testPlaceholder() {
// Placeholder test - all actual tests are in dedicated test files
XCTAssertTrue(true)
}
}

View File

@@ -0,0 +1,79 @@
//
// ReadingPreferencesTests.swift
// RSSuperTests
//
// Created by Mike Freno on 3/29/26.
//
import XCTest
@testable import RSSuper
final class ReadingPreferencesTests: XCTestCase {
func testReadingPreferencesEncodingDecoding() throws {
let prefs = ReadingPreferences(
fontSize: .large,
lineHeight: .loose,
showTableOfContents: true,
showReadingTime: false,
showAuthor: true,
showDate: false
)
let data = try JSONEncoder().encode(prefs)
let decoded = try JSONDecoder().decode(ReadingPreferences.self, from: data)
XCTAssertEqual(decoded.fontSize, prefs.fontSize)
XCTAssertEqual(decoded.lineHeight, prefs.lineHeight)
XCTAssertEqual(decoded.showTableOfContents, prefs.showTableOfContents)
XCTAssertEqual(decoded.showReadingTime, prefs.showReadingTime)
XCTAssertEqual(decoded.showAuthor, prefs.showAuthor)
XCTAssertEqual(decoded.showDate, prefs.showDate)
}
func testReadingPreferencesDefaults() {
let prefs = ReadingPreferences()
XCTAssertEqual(prefs.fontSize, .medium)
XCTAssertEqual(prefs.lineHeight, .relaxed)
XCTAssertEqual(prefs.showTableOfContents, false)
XCTAssertEqual(prefs.showReadingTime, true)
XCTAssertEqual(prefs.showAuthor, true)
XCTAssertEqual(prefs.showDate, true)
}
func testFontSizePointValue() {
XCTAssertEqual(ReadingPreferences.FontSize.small.pointValue, 14)
XCTAssertEqual(ReadingPreferences.FontSize.medium.pointValue, 16)
XCTAssertEqual(ReadingPreferences.FontSize.large.pointValue, 18)
XCTAssertEqual(ReadingPreferences.FontSize.xlarge.pointValue, 20)
}
func testLineHeightMultiplier() {
XCTAssertEqual(ReadingPreferences.LineHeight.normal.multiplier, 1.2)
XCTAssertEqual(ReadingPreferences.LineHeight.relaxed.multiplier, 1.5)
XCTAssertEqual(ReadingPreferences.LineHeight.loose.multiplier, 1.8)
}
func testReadingPreferencesEquality() {
let prefs1 = ReadingPreferences(fontSize: .large, lineHeight: .loose)
let prefs2 = ReadingPreferences(fontSize: .large, lineHeight: .loose)
let prefs3 = ReadingPreferences(fontSize: .medium, lineHeight: .normal)
XCTAssertEqual(prefs1, prefs2)
XCTAssertNotEqual(prefs1, prefs3)
}
func testReadingPreferencesDebugDescription() {
let prefs = ReadingPreferences(
fontSize: .xlarge,
lineHeight: .normal,
showTableOfContents: true
)
let debugDesc = prefs.debugDescription
XCTAssertTrue(debugDesc.contains("xlarge"))
XCTAssertTrue(debugDesc.contains("normal"))
XCTAssertTrue(debugDesc.contains("true"))
}
}

View File

@@ -0,0 +1,96 @@
//
// SearchFiltersTests.swift
// RSSuperTests
//
// Created by Mike Freno on 3/29/26.
//
import XCTest
@testable import RSSuper
final class SearchFiltersTests: XCTestCase {
func testSearchFiltersEncodingDecoding() throws {
let filters = SearchFilters(
dateFrom: Date(timeIntervalSince1970: 1609459200),
dateTo: Date(timeIntervalSince1970: 1609545600),
feedIds: ["feed-1", "feed-2"],
authors: ["Author 1", "Author 2"],
contentType: .audio
)
let data = try JSONEncoder().encode(filters)
let decoded = try JSONDecoder().decode(SearchFilters.self, from: data)
XCTAssertEqual(decoded.dateFrom, filters.dateFrom)
XCTAssertEqual(decoded.dateTo, filters.dateTo)
XCTAssertEqual(decoded.feedIds, filters.feedIds)
XCTAssertEqual(decoded.authors, filters.authors)
XCTAssertEqual(decoded.contentType, filters.contentType)
}
func testSearchFiltersEmpty() throws {
let filters = SearchFilters()
XCTAssertNil(filters.dateFrom)
XCTAssertNil(filters.dateTo)
XCTAssertNil(filters.feedIds)
XCTAssertNil(filters.authors)
XCTAssertNil(filters.contentType)
let data = try JSONEncoder().encode(filters)
let decoded = try JSONDecoder().decode(SearchFilters.self, from: data)
XCTAssertNil(decoded.dateFrom)
XCTAssertNil(decoded.feedIds)
}
func testSearchFiltersEquality() {
let filters1 = SearchFilters(
feedIds: ["feed-1"],
authors: ["Author 1"]
)
let filters2 = SearchFilters(
feedIds: ["feed-1"],
authors: ["Author 1"]
)
let filters3 = SearchFilters(
feedIds: ["feed-2"],
authors: ["Author 2"]
)
XCTAssertEqual(filters1, filters2)
XCTAssertNotEqual(filters1, filters3)
}
func testSearchSortOptionLocalizedDescription() {
XCTAssertEqual(SearchSortOption.relevance.localizedDescription, "Relevance")
XCTAssertEqual(SearchSortOption.dateDesc.localizedDescription, "Date (Newest First)")
XCTAssertEqual(SearchSortOption.dateAsc.localizedDescription, "Date (Oldest First)")
XCTAssertEqual(SearchSortOption.titleAsc.localizedDescription, "Title (A-Z)")
XCTAssertEqual(SearchSortOption.titleDesc.localizedDescription, "Title (Z-A)")
XCTAssertEqual(SearchSortOption.feedAsc.localizedDescription, "Feed (A-Z)")
XCTAssertEqual(SearchSortOption.feedDesc.localizedDescription, "Feed (Z-A)")
}
func testContentTypeLocalizedDescription() {
XCTAssertEqual(ContentType.article.localizedDescription, "Article")
XCTAssertEqual(ContentType.audio.localizedDescription, "Audio")
XCTAssertEqual(ContentType.video.localizedDescription, "Video")
}
func testSearchFiltersDebugDescription() {
let filters = SearchFilters(
dateFrom: Date(timeIntervalSince1970: 1609459200),
feedIds: ["feed-1", "feed-2"],
contentType: .video
)
let debugDesc = filters.debugDescription
XCTAssertTrue(debugDesc.contains("feed-1"))
XCTAssertTrue(debugDesc.contains("feed-2"))
XCTAssertTrue(debugDesc.contains("Video"))
}
}

View File

@@ -0,0 +1,122 @@
import XCTest
@testable import RSSuper
/// Unit tests for SearchHistoryStore
final class SearchHistoryStoreTests: XCTestCase {
private var historyStore: SearchHistoryStore!
private var databaseManager: DatabaseManager!
override func setUp() async throws {
// Create in-memory database for testing
databaseManager = try await DatabaseManager.inMemory()
historyStore = SearchHistoryStore(databaseManager: databaseManager, maxHistoryCount: 10)
try await historyStore.initialize()
}
override func tearDown() async throws {
historyStore = nil
databaseManager = nil
}
func testRecordSearch() async throws {
try await historyStore.recordSearch("test query")
let exists = try await historyStore.queryExists("test query")
XCTAssertTrue(exists)
}
func testRecordSearchUpdatesExisting() async throws {
try await historyStore.recordSearch("test query")
let firstCount = try await historyStore.getTotalCount()
try await historyStore.recordSearch("test query")
let secondCount = try await historyStore.getTotalCount()
XCTAssertEqual(firstCount, secondCount) // Should be same, updated not inserted
}
func testGetRecentQueries() async throws {
try await historyStore.recordSearch("query 1")
try await historyStore.recordSearch("query 2")
try await historyStore.recordSearch("query 3")
let queries = try await historyStore.getRecentQueries(limit: 2)
XCTAssertEqual(queries.count, 2)
XCTAssertEqual(queries[0], "query 3") // Most recent first
XCTAssertEqual(queries[1], "query 2")
}
func testGetHistoryWithMetadata() async throws {
try await historyStore.recordSearch("test query", resultCount: 42)
let entries = try await historyStore.getHistoryWithMetadata(limit: 10)
XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries[0].query, "test query")
XCTAssertEqual(entries[0].resultCount, 42)
}
func testRemoveQuery() async throws {
try await historyStore.recordSearch("to remove")
XCTAssertTrue(try await historyStore.queryExists("to remove"))
try await historyStore.removeQuery("to remove")
XCTAssertFalse(try await historyStore.queryExists("to remove"))
}
func testClearHistory() async throws {
try await historyStore.recordSearch("query 1")
try await historyStore.recordSearch("query 2")
XCTAssertEqual(try await historyStore.getTotalCount(), 2)
try await historyStore.clearHistory()
XCTAssertEqual(try await historyStore.getTotalCount(), 0)
}
func testTrimHistory() async throws {
// Insert more than maxHistoryCount
for i in 1...15 {
try await historyStore.recordSearch("query \(i)")
}
let count = try await historyStore.getTotalCount()
XCTAssertEqual(count, 10) // Should be trimmed to maxHistoryCount
}
func testGetPopularQueries() async throws {
// Record queries with different frequencies
try await historyStore.recordSearch("popular")
try await historyStore.recordSearch("popular")
try await historyStore.recordSearch("popular")
try await historyStore.recordSearch("less popular")
try await historyStore.recordSearch("less popular")
try await historyStore.recordSearch("once")
let popular = try await historyStore.getPopularQueries(limit: 10)
XCTAssertEqual(popular.count, 3)
XCTAssertEqual(popular[0].query, "popular")
XCTAssertEqual(popular[0].count, 3)
}
func testGetTodaysQueries() async throws {
try await historyStore.recordSearch("today query 1")
try await historyStore.recordSearch("today query 2")
let todays = try await historyStore.getTodaysQueries()
XCTAssertTrue(todays.contains("today query 1"))
XCTAssertTrue(todays.contains("today query 2"))
}
func testEmptyQueryIgnored() async throws {
try await historyStore.recordSearch("")
try await historyStore.recordSearch(" ")
let count = try await historyStore.getTotalCount()
XCTAssertEqual(count, 0)
}
}

View File

@@ -0,0 +1,111 @@
import XCTest
@testable import RSSuper
/// Unit tests for SearchQuery parsing and manipulation
final class SearchQueryTests: XCTestCase {
func testEmptyQuery() {
let query = SearchQuery(rawValue: "")
XCTAssertEqual(query.terms, [])
XCTAssertEqual(query.rawText, "")
XCTAssertEqual(query.sort, .relevance)
XCTAssertFalse(query.fuzzy)
}
func testSimpleQuery() {
let query = SearchQuery(rawValue: "swift programming")
XCTAssertEqual(query.terms, ["swift", "programming"])
XCTAssertEqual(query.rawText, "swift programming")
}
func testQueryWithDateFilter() {
let query = SearchQuery(rawValue: "swift date:after:2024-01-01")
XCTAssertEqual(query.terms, ["swift"])
XCTAssertNotNil(query.filters.dateRange)
if case .after(let date) = query.filters.dateRange! {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let expectedDate = formatter.date(from: "2024-01-01")!
XCTAssertEqual(date, expectedDate)
} else {
XCTFail("Expected .after case")
}
}
func testQueryWithFeedFilter() {
let query = SearchQuery(rawValue: "swift feed:Apple Developer")
XCTAssertEqual(query.terms, ["swift"])
XCTAssertEqual(query.filters.feedTitle, "Apple Developer")
}
func testQueryWithAuthorFilter() {
let query = SearchQuery(rawValue: "swift author:John Doe")
XCTAssertEqual(query.terms, ["swift"])
XCTAssertEqual(query.filters.author, "John Doe")
}
func testQueryWithSortOption() {
let query = SearchQuery(rawValue: "swift sort:date_desc")
XCTAssertEqual(query.terms, ["swift"])
XCTAssertEqual(query.sort, .dateDesc)
}
func testQueryWithFuzzyFlag() {
let query = SearchQuery(rawValue: "swift ~")
XCTAssertEqual(query.terms, ["swift"])
XCTAssertTrue(query.fuzzy)
}
func testFTSQueryGeneration() {
let exactQuery = SearchQuery(rawValue: "swift programming")
XCTAssertEqual(exactQuery.ftsQuery(), "\"swift\" OR \"programming\"")
let fuzzyQuery = SearchQuery(rawValue: "swift ~")
XCTAssertEqual(fuzzyQuery.ftsQuery(), "\"*swift*\"")
}
func testDisplayString() {
let query = SearchQuery(rawValue: "swift date:after:2024-01-01")
XCTAssertEqual(query.displayString, "swift")
}
func testDateRangeLowerBound() {
let afterRange = DateRange.after(Date())
XCTAssertNotNil(afterRange.lowerBound)
XCTAssertNil(afterRange.upperBound)
let beforeRange = DateRange.before(Date())
XCTAssertNil(beforeRange.lowerBound)
XCTAssertNotNil(beforeRange.upperBound)
let exactRange = DateRange.exact(Date())
XCTAssertNotNil(exactRange.lowerBound)
XCTAssertNotNil(exactRange.upperBound)
}
func testSearchFiltersIsEmpty() {
var filters = SearchFilters()
XCTAssertTrue(filters.isEmpty)
filters.dateRange = .after(Date())
XCTAssertFalse(filters.isEmpty)
filters = .empty
XCTAssertTrue(filters.isEmpty)
}
func testSortOptionOrderByClause() {
XCTAssertEqual(SearchSortOption.relevance.orderByClause(), "rank")
XCTAssertEqual(SearchSortOption.dateDesc.orderByClause(), "f.published DESC")
XCTAssertEqual(SearchSortOption.titleAsc.orderByClause(), "f.title ASC")
XCTAssertEqual(SearchSortOption.feedDesc.orderByClause(), "s.title DESC")
}
}

View File

@@ -0,0 +1,89 @@
import XCTest
@testable import RSSuper
/// Unit tests for SearchResult and related types
final class SearchResultTests: XCTestCase {
func testArticleResultCreation() {
let result = SearchResult.article(
id: "article-123",
title: "Test Article",
snippet: "This is a snippet",
link: "https://example.com/article",
feedTitle: "Test Feed",
published: Date(),
score: 0.95,
author: "Test Author"
)
XCTAssertEqual(result.id, "article-123")
XCTAssertEqual(result.type, .article)
XCTAssertEqual(result.title, "Test Article")
XCTAssertEqual(result.snippet, "This is a snippet")
XCTAssertEqual(result.link, "https://example.com/article")
XCTAssertEqual(result.feedTitle, "Test Feed")
XCTAssertEqual(result.score, 0.95)
XCTAssertEqual(result.author, "Test Author")
}
func testFeedResultCreation() {
let result = SearchResult.feed(
id: "feed-456",
title: "Test Feed",
link: "https://example.com/feed.xml",
score: 0.85
)
XCTAssertEqual(result.id, "feed-456")
XCTAssertEqual(result.type, .feed)
XCTAssertEqual(result.title, "Test Feed")
XCTAssertEqual(result.link, "https://example.com/feed.xml")
XCTAssertEqual(result.score, 0.85)
}
func testSuggestionResultCreation() {
let result = SearchResult.suggestion(
text: "swift programming",
score: 0.75
)
XCTAssertEqual(result.type, .suggestion)
XCTAssertEqual(result.title, "swift programming")
XCTAssertEqual(result.score, 0.75)
}
func testSearchResultTypeEncoding() {
XCTAssertEqual(SearchResultType.article.rawValue, "article")
XCTAssertEqual(SearchResultType.feed.rawValue, "feed")
XCTAssertEqual(SearchResultType.suggestion.rawValue, "suggestion")
XCTAssertEqual(SearchResultType.tag.rawValue, "tag")
XCTAssertEqual(SearchResultType.author.rawValue, "author")
}
func testSearchResultEquatable() {
let result1 = SearchResult.article(id: "1", title: "Test")
let result2 = SearchResult.article(id: "1", title: "Test")
let result3 = SearchResult.article(id: "2", title: "Test")
XCTAssertEqual(result1, result2)
XCTAssertNotEqual(result1, result3)
}
func testSearchResults totalCount() {
let results = SearchResults(
articles: [SearchResult.article(id: "1", title: "A")],
feeds: [SearchResult.feed(id: "2", title: "F")],
suggestions: []
)
XCTAssertEqual(results.totalCount, 2)
XCTAssertTrue(results.hasResults)
}
func testSearchResultsEmpty() {
let results = SearchResults(articles: [], feeds: [], suggestions: [])
XCTAssertEqual(results.totalCount, 0)
XCTAssertFalse(results.hasResults)
}
}

View File

@@ -0,0 +1,76 @@
import XCTest
@testable import RSSuper
/// Unit tests for SyncScheduler
final class SyncSchedulerTests: XCTestCase {
private var scheduler: SyncScheduler!
override func setUp() {
super.setUp()
scheduler = SyncScheduler()
}
override func tearDown() {
scheduler = nil
super.tearDown()
}
func testDefaultSyncInterval() {
XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.defaultSyncInterval)
}
func testSyncIntervalClamping() {
// Test minimum clamping
scheduler.preferredSyncInterval = 60 // 1 minute
XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.minimumSyncInterval)
// Test maximum clamping
scheduler.preferredSyncInterval = 48 * 3600 // 48 hours
XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.maximumSyncInterval)
}
func testIsSyncDue() {
// Fresh scheduler should have sync due
XCTAssertTrue(scheduler.isSyncDue)
// Set last sync date to recent past
scheduler.lastSyncDate = Date().addingTimeInterval(-1 * 3600) // 1 hour ago
XCTAssertFalse(scheduler.isSyncDue)
// Set last sync date to far past
scheduler.lastSyncDate = Date().addingTimeInterval(-12 * 3600) // 12 hours ago
XCTAssertTrue(scheduler.isSyncDue)
}
func testTimeSinceLastSync() {
scheduler.lastSyncDate = Date().addingTimeInterval(-3600) // 1 hour ago
let timeSince = scheduler.timeSinceLastSync
XCTAssertGreaterThan(timeSince, 3500)
XCTAssertLessThan(timeSince, 3700)
}
func testResetSyncSchedule() {
scheduler.preferredSyncInterval = 12 * 3600
scheduler.lastSyncDate = Date().addingTimeInterval(-100)
scheduler.resetSyncSchedule()
XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.defaultSyncInterval)
XCTAssertNil(scheduler.lastSyncDate)
}
func testUserActivityLevelCalculation() {
// High activity
XCTAssertEqual(UserActivityLevel.calculate(from: 5, lastOpenedAgo: 3600), .high)
XCTAssertEqual(UserActivityLevel.calculate(from: 1, lastOpenedAgo: 60), .high)
// Medium activity
XCTAssertEqual(UserActivityLevel.calculate(from: 2, lastOpenedAgo: 3600), .medium)
XCTAssertEqual(UserActivityLevel.calculate(from: 0, lastOpenedAgo: 43200), .medium)
// Low activity
XCTAssertEqual(UserActivityLevel.calculate(from: 0, lastOpenedAgo: 172800), .low)
}
}