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 = #""" 第一篇 /one x

]]>
正文]]> today
第二篇/two
"""# let result = try RSSDefaultParser.parse(sortName: "全部", xml: xml, sourceURL: "https://rss") #expect(result.nextURL == nil) #expect(result.articles.count == 2) #expect(result.articles[0].title == "第一篇") #expect(result.articles[0].image == "description.jpg") #expect(result.articles[0].content?.contains("正文") == true) #expect(result.articles[0].origin == "https://rss") #expect(result.articles[0].sort == "全部") #expect(result.articles[1].image == "enclosure.jpg") #expect(result.articles[1].pubDate == "later") } @Test func rssRuleParserExtractsFieldsReversesAndSupportsPageSentinel() async throws { var source = RssSource(sourceUrl: "https://rss.example/root/", sourceName: "规则源") source.ruleArticles = "-@CSS:.item" source.ruleTitle = "@CSS:h2@text" source.rulePubDate = "@CSS:time@text" source.ruleDescription = "@CSS:p@text" source.ruleImage = "@CSS:img@src" source.ruleLink = "@CSS:a@href" source.ruleNextPage = "PAGE" source.type = 2 let body = #"""

甲

A

go

乙

B

go
"""# let report = try await RSSRuleParser().parse( sortName: "分类", sortURL: "https://rss.example/list?page=1", redirectURL: "https://rss.example/list?page=1", body: body, source: source ) #expect(report.result.articles.map(\.title) == ["乙", "甲"]) #expect(report.result.articles.first?.link == "https://rss.example/b") #expect(report.result.articles.first?.image == "https://rss.example/root/b.jpg") #expect(report.result.articles.first?.type == 2) #expect(report.result.nextURL == "https://rss.example/list?page=1") } @Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws { let importer = RSSSourceImporter() let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8)) #expect(object.sources.map(\.sourceUrl) == ["https://one"]) let array = try importer.decode(Data(#"[{"sourceUrl":"https://two","sourceName":"二"}]"#.utf8)) #expect(array.sources.map(\.sourceUrl) == ["https://two"]) let manifest = try importer.decode(Data(#"{"sourceUrls":["https://list/one.json","https://list/two.json"]}"#.utf8)) #expect(manifest.subscriptionURLs.count == 2) var old = RssSource(sourceUrl: "https://one", sourceName: "旧"); old.lastUpdateTime = 100 var newer = RssSource(sourceUrl: "https://one", sourceName: "新"); newer.lastUpdateTime = 200 #expect(importer.defaultSelection(incoming: [newer], existing: [old]) == [true]) let merged = try importer.merge([newer], into: [old], addGroup: "导入") #expect(merged.first?.sourceName == "新") #expect(merged.first?.groups.contains("导入") == true) #expect(try importer.decode(importer.encode(merged)).sources.first?.sourceUrl == "https://one") } @Test func rssSortResolverParsesSeparatorsAndFallback() async { var source = RssSource(sourceUrl: "https://rss", sourceName: "源") source.sortUrl = "新闻::/news&&科技::/tech\n生活::/life" let sorts = await RSSSortResolver().resolve(source) #expect(sorts.map(\.name) == ["新闻", "科技", "生活"]) #expect(sorts.map(\.url) == ["/news", "/tech", "/life"]) source.sortUrl = "invalid" #expect(await RSSSortResolver().resolve(source) == [.init(name: "", url: "https://rss")]) } @Test func rssReadingPolicyHandlesDescriptionRuleAndMediaBranches() { let policy = RSSReadingPolicy() var source = RssSource(sourceUrl: "https://rss.example", sourceName: "源") var article = RssArticle(origin: source.sourceUrl, sort: "", title: "文章", link: "/post/1") article.description = "

摘要正文

" #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: "
正文
") == .html(content: "
正文
", baseURL: "https://rss.example/post/1")) article.type = 1 #expect(policy.mode(article: article, source: source, resolvedContent: "../cover.jpg") == .image(url: "https://rss.example/cover.jpg")) article.type = 2 #expect(policy.mode(article: article, source: source, resolvedContent: "video.mp4") == .video(url: "https://rss.example/post/video.mp4")) } @Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() { let processor = RSSHTMLProcessor() #expect(processor.contentHTML("x", style: "body{color:red}").contains("")) #expect(processor.contentHTML("

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("")) #expect(start.contains("")) let blacklistWins = RSSResourcePolicy(blacklist: "https://ads,.*\\.track\\.js", whitelist: "https://ads/ok") #expect(!blacklistWins.allows("https://ads/ok")) #expect(!blacklistWins.allows("https://cdn.track.js")) #expect(blacklistWins.allows("https://cdn.example/a.js")) let whitelist = RSSResourcePolicy(blacklist: nil, whitelist: "https://safe,https://cdn\\.example/.*") #expect(whitelist.allows("https://safe/image")) #expect(!whitelist.allows("https://other/image")) #expect(blacklistWins.contentBlockerJSON()?.contains("\"type\":\"block\"") == true) #expect(whitelist.contentBlockerJSON()?.contains("ignore-previous-rules") == true) } @Test func rssStartPageUsesDedicatedStartJSAndStyle() throws { var source = RssSource(sourceUrl: "https://rss.example", sourceName: "源") source.startHtml = "

入口

" source.startJs = "window.started=true" source.startStyle = "p{color:green}" #expect(try RSSStartPageResolver().resolve(source) == .startHTML(content: "

入口

", baseURL: "https://rss.example")) source.startHtml = "@js:'脚本入口'" #expect(try RSSStartPageResolver().resolve(source) == .startHTML(content: "脚本入口", baseURL: "https://rss.example")) source.startHtml = nil #expect(try RSSStartPageResolver().resolve(source) == nil) } }