import Foundation
import Testing
@testable import LegadoCore
struct LegadoDatabaseTests {
@Test func decodesBookAndChapterFieldsWithoutBreakingLegacyPayloads() throws {
let payload = Data(#"""
{
"bookUrl":"book","name":"书","durVolumeIndex":2,"chapterInVolumeIndex":4,
"readConfig":{"reverseToc":true,"dailyChapters":5,"playSpeed":1.25}
}
"""#.utf8)
let book = try JSONDecoder().decode(Book.self, from: payload)
#expect(book.durVolumeIndex == 2)
#expect(book.chapterInVolumeIndex == 4)
#expect(book.readConfig?.reverseToc == true)
#expect(book.readConfig?.dailyChapters == 5)
let chapter = try JSONDecoder().decode(
BookChapter.self,
from: Data(#"{"url":"/1","bookUrl":"book","startFragmentId":"a","endFragmentId":"b","imgUrl":"cover"}"#.utf8)
)
#expect(chapter.startFragmentId == "a")
#expect(chapter.endFragmentId == "b")
#expect(chapter.imageURL == "cover")
let encoded = String(decoding: try JSONEncoder().encode(chapter), as: UTF8.self)
#expect(encoded.contains("\"imgUrl\":\"cover\""))
}
@Test func storesSourcesBooksAndChapterKeys() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let sources = SQLiteBookSourceRepository(database: database)
let books = SQLiteBookRepository(database: database)
let chapters = SQLiteBookChapterRepository(database: database)
var source = BookSource(bookSourceUrl: "https://source.example", bookSourceName: "示例")
source.customOrder = 7
try await sources.save([source])
var book = Book(bookUrl: "https://source.example/book/1", name: "书", author: "作者", origin: source.bookSourceUrl)
book.durChapterPos = 42
try await books.save([book])
var first = BookChapter(url: "/1", title: "第一章", bookUrl: book.bookUrl, index: 99)
first.variable = #"{"token":"x"}"#
let second = BookChapter(url: "/2", title: "第二章", bookUrl: book.bookUrl, index: 100)
try await chapters.save([first, second], bookURL: book.bookUrl)
#expect(try await sources.load() == [source])
#expect(try await books.load() == [book])
let stored = try await chapters.load(bookURL: book.bookUrl)
#expect(stored.map(\.index) == [0, 1])
#expect(stored.first?.variable == first.variable)
try await books.save([])
#expect(try await chapters.load(bookURL: book.bookUrl).isEmpty)
}
@Test func importsLegacyJSONOnlyIntoEmptyTables() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let sourceURL = directory.appending(path: "book-sources.json")
let booksURL = directory.appending(path: "books.json")
let source = BookSource(bookSourceUrl: "https://legacy.example", bookSourceName: "旧源")
let book = Book(bookUrl: "https://legacy.example/1", name: "旧书", origin: source.bookSourceUrl)
try JSONEncoder().encode([source]).write(to: sourceURL)
try JSONEncoder().encode([book]).write(to: booksURL)
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
try await database.importLegacyJSON(bookSourcesURL: sourceURL, booksURL: booksURL)
#expect(try await database.loadBookSources() == [source])
#expect(try await database.loadBooks() == [book])
try JSONEncoder().encode([BookSource(bookSourceUrl: "https://ignored", bookSourceName: "忽略")])
.write(to: sourceURL)
try await database.importLegacyJSON(bookSourcesURL: sourceURL, booksURL: booksURL)
#expect(try await database.loadBookSources() == [source])
}
@Test func doesNotReimportLegacyChaptersAfterRemovingBook() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let legacyDirectory = directory.appending(path: "Chapters", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true)
let book = Book(bookUrl: "https://source.example/book", name: "书", origin: "https://source.example")
let stale = BookChapter(url: "https://source.example/old", title: "后记", bookUrl: book.bookUrl, index: 0)
try JSONEncoder().encode([stale]).write(to: legacyDirectory.appending(path: "old.json"))
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let books = SQLiteBookRepository(database: database)
let chapters = SQLiteBookChapterRepository(database: database, legacyDirectory: legacyDirectory)
try await books.save([book])
#expect(try await chapters.load(bookURL: book.bookUrl).map(\.title) == ["后记"])
try await books.save([])
try await books.save([book])
#expect(try await chapters.load(bookURL: book.bookUrl).isEmpty)
}
@Test func storesBookmarksAndCompositeReadRecords() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let repository = SQLiteReadingActivityRepository(database: database)
let bookmark = Bookmark(time: 123, bookName: "书", bookAuthor: "作者")
let records = [
ReadRecord(deviceId: "A", bookName: "书", readTime: 10, lastRead: 100),
ReadRecord(deviceId: "B", bookName: "书", readTime: 20, lastRead: 200)
]
try await repository.saveBookmarks([bookmark])
try await repository.saveReadRecords(records)
#expect(try await repository.loadBookmarks() == [bookmark])
#expect(try await repository.loadReadRecords() == Array(records.reversed()))
}
@Test func storesBookGroupsInExplicitOrder() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let repository = SQLiteBookGroupRepository(database: database)
let groups = [
BookGroup(groupId: 2, groupName: "二", order: 9),
BookGroup(groupId: 1, groupName: "一", order: 1)
]
try await repository.save(groups)
#expect(try await repository.load() == groups)
}
@Test func searchKeywordUsageOrderingUsesCountAndTimestamp() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let repository = SQLiteSearchKeywordRepository(database: database, legacyWords: ["旧词"])
#expect(try await repository.load(order: .lastUseTime).map(\.word) == ["旧词"])
_ = try await repository.record("甲", at: 100)
_ = try await repository.record("乙", at: 200)
let updated = try await repository.record("甲", at: 300)
#expect(updated.usage == 2)
#expect(try await repository.load(order: .usage).map(\.word).first == "甲")
#expect(try await repository.load(order: .lastUseTime).map(\.word).first == "甲")
try await repository.delete("甲")
#expect(try await repository.load(order: .usage).allSatisfy { $0.word != "甲" })
}
@Test func preservesCacheDeadlinesSourceCleanupAndCookieKeys() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let caches = SQLiteLegadoCacheRepository(database: database)
let cookies = SQLiteLegadoCookieRepository(database: database)
try await caches.put(.init(key: "alive", value: "A", deadline: 200))
try await caches.put(.init(key: "expired", value: "E", deadline: 50))
try await caches.put(.init(key: "loginHeader_source", value: "H"))
#expect(try await caches.value(for: "alive", now: 100) == "A")
#expect(try await caches.value(for: "expired", now: 100) == nil)
try await caches.clearExpired(now: 100)
try await caches.deleteSourceVariables("source")
#expect(try await caches.value(for: "loginHeader_source", now: 100) == nil)
try await cookies.put(.init(url: "host", cookie: "a=1"))
try await cookies.put(.init(url: "source|example.com|/", cookie: "b=2"))
#expect(try await cookies.get("host")?.cookie == "a=1")
#expect(try await cookies.getOkHTTP().count == 1)
try await cookies.deleteOkHTTP()
#expect(try await cookies.getOkHTTP().isEmpty)
}
@Test func searchBookStoreUsesSourceEnableGroupAndOrderForChangeSource() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let sourceRepository = SQLiteBookSourceRepository(database: database)
let repository = SQLiteSearchBookRepository(database: database)
var firstSource = BookSource(bookSourceUrl: "https://one", bookSourceName: "一号")
firstSource.bookSourceGroup = "文字"
firstSource.customOrder = 20
var secondSource = BookSource(bookSourceUrl: "https://two", bookSourceName: "二号")
secondSource.bookSourceGroup = "文字,精选"
secondSource.customOrder = 10
try await sourceRepository.save([firstSource, secondSource])
var one = SearchBook(bookURL: "https://one/book", origin: firstSource.bookSourceUrl, originName: "一号", type: 0, name: "书", author: "作者")
one.latestChapterTitle = "第一章"
one.originOrder = 20
var two = SearchBook(bookURL: "https://two/book", origin: secondSource.bookSourceUrl, originName: "二号", type: 0, name: "书", author: "作者")
two.coverURL = "cover"
two.originOrder = 10
try await repository.put([one, two])
#expect(try await repository.first(name: "书", author: "作者")?.bookURL == two.bookURL)
let changed = try await repository.changeSources(name: "书", author: "作", sourceGroup: "文字", key: "二号")
#expect(changed.map(\.bookURL) == [two.bookURL])
#expect(changed.first?.originOrder == 10)
#expect(try await repository.enabledWithCover(name: "书", author: "作者").map(\.bookURL) == [two.bookURL])
secondSource.enabled = false
try await sourceRepository.save([firstSource, secondSource])
#expect(try await repository.enabledWithCover(name: "书", author: "作者").isEmpty)
#expect(try await repository.get(bookURL: two.bookURL) != nil)
}
@Test func storesConfigurationEntitiesWithOrderingAndKeys() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let repository = SQLiteConfigurationRepository(database: database)
let toc = [
TxtTocRule(id: 2, name: "后", serialNumber: 2),
TxtTocRule(id: 1, name: "前", serialNumber: 1)
]
try await repository.saveTxtTocRules(toc)
#expect(try await repository.loadTxtTocRules().map(\.id) == [1, 2])
let engines = [HttpTTS(id: 2, name: "B"), HttpTTS(id: 1, name: "A")]
try await repository.saveHttpTTS(engines)
#expect(try await repository.loadHttpTTS().map(\.name) == ["A", "B"])
let subscriptions = [
RuleSub(id: 2, name: "后", url: "2"),
RuleSub(id: 1, name: "前", url: "1")
]
var orderedSubscriptions = subscriptions
orderedSubscriptions[0].customOrder = 2
orderedSubscriptions[1].customOrder = 1
try await repository.saveRuleSubs(orderedSubscriptions)
#expect(try await repository.loadRuleSubs().map(\.id) == [1, 2])
let keys = [
KeyboardAssist(type: 1, key: "b", value: "B", serialNo: 2),
KeyboardAssist(type: 0, key: "a", value: "A", serialNo: 1)
]
try await repository.saveKeyboardAssists(keys)
#expect(try await repository.loadKeyboardAssists().map(\.key) == ["a", "b"])
var serverB = Server(id: 2, name: "B")
serverB.sortNumber = 2
var serverA = Server(id: 1, name: "A")
serverA.sortNumber = 1
try await repository.saveServers([serverB, serverA])
#expect(try await repository.loadServers().map(\.id) == [1, 2])
}
@Test func decodesRssModelsAndPreservesDefaults() throws {
let source = try JSONDecoder().decode(
RssSource.self,
from: Data(#"{"sourceUrl":"https://rss","sourceName":"订阅","enabled":"false","articleStyle":"2","loginUi":{"type":"input"}}"#.utf8)
)
#expect(source.enabled == false)
#expect(source.articleStyle == 2)
#expect(source.loginUi?.contains("input") == true)
#expect(source.enableJs == true)
let article = try JSONDecoder().decode(
RssArticle.self,
from: Data(#"{"origin":"https://rss","sort":"all","title":"文章","link":"/1","order":"42","read":1}"#.utf8)
)
#expect(article.order == 42)
#expect(article.read)
#expect(article.record(at: 100).record == "/1")
#expect(article.star(at: 200).starTime == 200)
}
@Test func rssSourceRepositorySupportsGroupsSearchAndOrdering() async throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let repository = SQLiteRssSourceRepository(database: database)
var later = RssSource(sourceUrl: "https://b", sourceName: "科技日报")
later.sourceGroup = "资讯,科技"; later.customOrder = 20
var earlier = RssSource(sourceUrl: "https://a", sourceName: "新闻")
earlier.sourceGroup = "资讯"; earlier.sourceComment = "每日"; earlier.customOrder = 10
try await repository.save([later, earlier])
#expect(try await repository.all().map(\.sourceUrl) == ["https://a", "https://b"])
#expect(Set(try await repository.groups()) == Set(["资讯", "科技"]))
#expect(try await repository.inGroup("科技").map(\.sourceUrl) == ["https://b"])
#expect(try await repository.search("每日").map(\.sourceUrl) == ["https://a"])
try await repository.setEnabled(false, sourceURL: "https://a")
#expect(try await repository.disabled().map(\.sourceUrl) == ["https://a"])
}
@Test func rssArticlesUseCompositeKeyIgnoreAppendAndGlobalReadLink() async throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let articles = SQLiteRssArticleRepository(database: database)
let records = SQLiteRssReadRecordRepository(database: database)
var old = RssArticle(origin: "one", sort: "all", title: "旧标题", link: "/same")
old.order = 1
try await articles.append([old])
var duplicate = old; duplicate.title = "不应覆盖"; duplicate.order = 99
var secondSort = old; secondSort.sort = "other"; secondSort.title = "另一分类"
try await articles.append([duplicate, secondSort])
#expect(try await articles.articles(origin: "one", sort: "all").first?.title == "旧标题")
#expect(try await articles.articles(origin: "one", sort: "other").count == 1)
try await records.insertIgnoringExisting(old.record(at: 100))
var sameLinkOtherOrigin = RssArticle(origin: "two", sort: "all", title: "同链接", link: "/same")
sameLinkOtherOrigin.order = 2
try await articles.append([sameLinkOtherOrigin])
#expect(try await articles.articles(origin: "two", sort: "all").first?.read == true)
try await articles.clearOld(origin: "one", sort: "all", before: 2)
#expect(try await articles.articles(origin: "one", sort: "all").isEmpty)
try await articles.updateOrigin(from: "one", to: "renamed")
#expect(try await articles.article(origin: "renamed", link: "/same", sort: "other")?.origin == "renamed")
}
@Test func rssStarsReplaceCompositeKeyAndSortNewestFirst() async throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
let stars = SQLiteRssStarRepository(database: database)
var firstArticle = RssArticle(origin: "one", sort: "all", title: "甲", link: "/1")
firstArticle.group = "技术"
var secondArticle = RssArticle(origin: "one", sort: "all", title: "乙", link: "/2")
secondArticle.group = "生活"
try await stars.save(firstArticle.star(at: 100))
try await stars.save(secondArticle.star(at: 200))
var replacement = firstArticle.star(at: 300); replacement.title = "甲更新"
try await stars.save(replacement)
#expect(try await stars.all().map(\.title) == ["甲更新", "乙"])
#expect(Set(try await stars.groups()) == Set(["技术", "生活"]))
try await stars.updateOrigin(from: "one", to: "renamed")
#expect(try await stars.star(origin: "renamed", link: "/1")?.origin == "renamed")
try await stars.remove(origin: "renamed", link: "/1")
#expect(try await stars.all().map(\.link) == ["/2"])
}
@Test func defaultRssParserExtractsItemFieldsAndPrioritizesImages() throws {
let xml = #"""
]]>
摘要正文
" #expect(policy.mode(article: article, source: source) == .html(content: "摘要正文
", baseURL: "https://rss.example/post/1")) article.description = nil #expect(policy.mode(article: article, source: source) == .web(url: "https://rss.example/post/1")) source.ruleContent = "@CSS:article@html" #expect(policy.mode(article: article, source: source, resolvedContent: "x
", style: nil).hasPrefix("")) var source = RssSource(sourceUrl: "https://rss", sourceName: "源") source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}" let start = processor.startHTML("x
", source: source) #expect(start.contains("入口
入口
脚本入口脚本入口