import Testing @testable import bookstax import Foundation // MARK: - TagDTO @Suite("TagDTO") struct TagDTOTests { @Test("id is composed of name and value") func idFormat() { let tag = TagDTO(name: "status", value: "published", order: 0) #expect(tag.id == "status:published") } @Test("id with empty value still includes colon separator") func idEmptyValue() { let tag = TagDTO(name: "featured", value: "", order: 0) #expect(tag.id == "featured:") } @Test("Two tags with same name/value have equal ids") func duplicateIds() { let t1 = TagDTO(name: "env", value: "prod", order: 0) let t2 = TagDTO(name: "env", value: "prod", order: 1) #expect(t1.id == t2.id) } @Test("Tags decode from JSON correctly") func decodeFromJSON() throws { let json = """ {"name":"topic","value":"swift","order":3} """.data(using: .utf8)! let tag = try JSONDecoder().decode(TagDTO.self, from: json) #expect(tag.name == "topic") #expect(tag.value == "swift") #expect(tag.order == 3) } } // MARK: - PageDTO private let iso8601Formatter: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() private func pageJSON( slug: String? = nil, priority: Int? = nil, draft: Bool? = nil, tags: String? = nil, markdown: String? = nil ) -> Data { var fields: [String] = [ #""id":1,"book_id":10,"name":"Test Page""#, #""created_at":"2024-01-01T00:00:00.000Z","updated_at":"2024-01-01T00:00:00.000Z""# ] if let s = slug { fields.append("\"slug\":\"\(s)\"") } if let p = priority { fields.append("\"priority\":\(p)") } if let d = draft { fields.append("\"draft\":\(d)") } if let t = tags { fields.append("\"tags\":[\(t)]") } if let m = markdown { fields.append("\"markdown\":\"\(m)\"") } return ("{\(fields.joined(separator: ","))}").data(using: .utf8)! } private func makeDecoder() -> JSONDecoder { // Mirrors the decoder used in BookStackAPI let decoder = JSONDecoder() let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let str = try container.decode(String.self) if let date = formatter.date(from: str) { return date } // Fallback without fractional seconds let fallback = ISO8601DateFormatter() if let date = fallback.date(from: str) { return date } throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date: \(str)") } return decoder } @Suite("PageDTO – JSON Decoding") struct PageDTOTests { @Test("Minimal JSON (no optional fields) decodes with defaults") func minimalDecoding() throws { let data = pageJSON() let page = try makeDecoder().decode(PageDTO.self, from: data) #expect(page.id == 1) #expect(page.bookId == 10) #expect(page.name == "Test Page") #expect(page.slug == "") // defaults to "" #expect(page.priority == 0) // defaults to 0 #expect(page.draftStatus == false) // defaults to false #expect(page.tags.isEmpty) // defaults to [] #expect(page.markdown == nil) #expect(page.html == nil) #expect(page.chapterId == nil) } @Test("slug is decoded when present") func slugDecoded() throws { let data = pageJSON(slug: "my-page") let page = try makeDecoder().decode(PageDTO.self, from: data) #expect(page.slug == "my-page") } @Test("priority is decoded when present") func priorityDecoded() throws { let data = pageJSON(priority: 5) let page = try makeDecoder().decode(PageDTO.self, from: data) #expect(page.priority == 5) } @Test("draft true decodes correctly") func draftDecoded() throws { let data = pageJSON(draft: true) let page = try makeDecoder().decode(PageDTO.self, from: data) #expect(page.draftStatus == true) } @Test("tags array is decoded when present") func tagsDecoded() throws { let tagJSON = #"{"name":"lang","value":"swift","order":0}"# let data = pageJSON(tags: tagJSON) let page = try makeDecoder().decode(PageDTO.self, from: data) #expect(page.tags.count == 1) #expect(page.tags.first?.name == "lang") } @Test("markdown is decoded when present") func markdownDecoded() throws { let data = pageJSON(markdown: "# Hello") let page = try makeDecoder().decode(PageDTO.self, from: data) #expect(page.markdown == "# Hello") } } // MARK: - SearchResultDTO @Suite("SearchResultDTO – JSON Decoding") struct SearchResultDTOTests { private func resultJSON(withTags: Bool = false) -> Data { let tags = withTags ? #","tags":[{"name":"topic","value":"swift","order":0}]"# : "" return """ {"id":7,"name":"Swift Basics","slug":"swift-basics","type":"page", "url":"https://example.com/books/1/page/7","preview":"An intro to Swift"\(tags)} """.data(using: .utf8)! } @Test("Result with no tags field defaults to empty array") func defaultsEmptyTags() throws { let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON()) #expect(result.tags.isEmpty) } @Test("Result with tags decodes them correctly") func decodesTagsWhenPresent() throws { let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON(withTags: true)) #expect(result.tags.count == 1) } @Test("All fields decode correctly") func allFieldsDecode() throws { let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON()) #expect(result.id == 7) #expect(result.name == "Swift Basics") #expect(result.slug == "swift-basics") #expect(result.type == .page) #expect(result.preview == "An intro to Swift") } } // MARK: - SearchResultDTO.ContentType @Suite("SearchResultDTO.ContentType") struct ContentTypeTests { @Test("All content types have non-empty systemImage") func systemImagesNonEmpty() { for type_ in SearchResultDTO.ContentType.allCases { #expect(!type_.systemImage.isEmpty) } } @Test("Page system image is doc.text") func pageSystemImage() { #expect(SearchResultDTO.ContentType.page.systemImage == "doc.text") } @Test("Book system image is book.closed") func bookSystemImage() { #expect(SearchResultDTO.ContentType.book.systemImage == "book.closed") } @Test("Chapter system image is list.bullet.rectangle") func chapterSystemImage() { #expect(SearchResultDTO.ContentType.chapter.systemImage == "list.bullet.rectangle") } @Test("Shelf system image is books.vertical") func shelfSystemImage() { #expect(SearchResultDTO.ContentType.shelf.systemImage == "books.vertical") } @Test("ContentType decodes from raw string value") func rawValueDecoding() throws { let data = #""page""#.data(using: .utf8)! let type_ = try JSONDecoder().decode(SearchResultDTO.ContentType.self, from: data) #expect(type_ == .page) } } // MARK: - PaginatedResponse @Suite("PaginatedResponse – Decoding") struct PaginatedResponseTests { @Test("Paginated shelf response decodes total and data") func paginatedDecoding() throws { let json = """ {"data":[{"id":1,"name":"My Shelf","slug":"my-shelf","description":"", "created_at":"2024-01-01T00:00:00.000Z","updated_at":"2024-01-01T00:00:00.000Z"}], "total":1} """.data(using: .utf8)! let decoded = try makeDecoder().decode(PaginatedResponse.self, from: json) #expect(decoded.total == 1) #expect(decoded.data.count == 1) #expect(decoded.data.first?.name == "My Shelf") } }