From 5f1c4847815c41d94f7cc7181b104f381641ea7a Mon Sep 17 00:00:00 2001 From: Sam Clemente Date: Thu, 20 Aug 2026 18:11:24 -0500 Subject: [PATCH] feat: add bounded bookmark tag management Adds exact tag filtering and pagination plus CID-guarded replacement, rename, and delete operations. Bounded mutation and verification cursors make global changes retryable without hiding partial progress. --- Sources/LatrKit/Library/BookmarkLibrary.swift | 239 +++++++++++- .../LatrKit/Library/SavedLibraryError.swift | 1 + Sources/LatrKit/Models/BookmarkTags.swift | 110 ++++++ Sources/LatrKit/XRPC/LatrXRPCContracts.swift | 57 ++- Sources/LatrKit/XRPC/LatrXRPCMethod.swift | 6 +- Tests/LatrKitTests/BookmarkLibraryTests.swift | 365 +++++++++++++++++- Tests/LatrKitTests/InMemoryRepository.swift | 21 +- Tests/LatrKitTests/XRPCContractTests.swift | 106 ++++- 8 files changed, 891 insertions(+), 14 deletions(-) create mode 100644 Sources/LatrKit/Models/BookmarkTags.swift diff --git a/Sources/LatrKit/Library/BookmarkLibrary.swift b/Sources/LatrKit/Library/BookmarkLibrary.swift index 01c79c2..b0e448e 100644 --- a/Sources/LatrKit/Library/BookmarkLibrary.swift +++ b/Sources/LatrKit/Library/BookmarkLibrary.swift @@ -1,7 +1,12 @@ import Foundation public extension SavedLibrary { - func bookmarks(limit: Int = 50, startingAfter cursor: String? = nil) async throws -> BookmarkList { + func bookmarks( + limit: Int = 50, + startingAfter cursor: String? = nil, + taggedWith rawTag: String? = nil + ) async throws -> BookmarkList { + let tag = try rawTag.map(BookmarkTags.normalized) let page: RecordList = try await repository.listRecords( in: repositoryDID, collection: .bookmark, @@ -10,7 +15,10 @@ public extension SavedLibrary { ) let metadataByKey = try await bookmarkMetadataByKey() var views: [BookmarkView] = [] - for record in page.records { + let matchingRecords = tag.map { selectedTag in + page.records.filter { $0.value.tags?.contains(selectedTag) == true } + } ?? page.records + for record in matchingRecords { guard let key = LexiconURI.recordKey(from: record.uri) else { throw SavedLibraryError.invalidStoredRecord(uri: record.uri) } @@ -20,6 +28,23 @@ public extension SavedLibrary { return BookmarkList(records: views, cursor: page.cursor) } + func bookmarkTags(limit: Int = 100, startingAfter cursor: String? = nil) async throws -> BookmarkTagList { + let page: RecordList = try await repository.listRecords( + in: repositoryDID, + collection: .bookmark, + limit: min(max(limit, 1), 100), + startingAfter: cursor + ) + var counts: [String: Int] = [:] + for record in page.records { + for tag in Set(record.value.tags ?? []) { + counts[tag, default: 0] += 1 + } + } + let tagCounts = counts.keys.sorted().map { BookmarkTagCount(tag: $0, count: counts[$0] ?? 0) } + return BookmarkTagList(tagCounts: tagCounts, scanned: page.records.count, cursor: page.cursor) + } + func bookmark(subject rawSubject: String) async throws -> BookmarkView? { let subject = try validatedBookmarkSubject(rawSubject) var matches: [RepositoryRecord] = [] @@ -89,7 +114,7 @@ public extension SavedLibrary { @discardableResult func saveBookmark(subject rawSubject: String, tags: [String]? = nil) async throws -> BookmarkView { let subject = try validatedBookmarkSubject(rawSubject) - let stableTags = tags.map { Array(Set($0)).sorted() } + let stableTags = try tags.map(BookmarkTags.normalized) if let existing = try await bookmark(subject: subject) { guard let key = LexiconURI.recordKey(from: existing.uri) else { throw SavedLibraryError.invalidStoredRecord(uri: existing.uri) @@ -97,7 +122,8 @@ public extension SavedLibrary { var writes: [RepositoryWrite] = [] var nextBookmark = existing.value if let stableTags { - nextBookmark.tags = Array(Set((nextBookmark.tags ?? []) + stableTags)).sorted() + let mergedTags = try BookmarkTags.merging(nextBookmark.tags ?? [], with: stableTags) + nextBookmark.tags = mergedTags.isEmpty ? nil : mergedTags if nextBookmark.tags != existing.value.tags { writes.append(try .updating(collection: .bookmark, key: key, value: nextBookmark, swapRecord: existing.cid)) } @@ -112,7 +138,11 @@ public extension SavedLibrary { let key = TID.now() let uri = "at://\(repositoryDID)/\(LexiconCollection.bookmark.identifier)/\(key)" - let bookmark = CommunityBookmark(subject: subject, createdAt: Timestamp.iso8601Now(), tags: stableTags) + let bookmark = CommunityBookmark( + subject: subject, + createdAt: Timestamp.iso8601Now(), + tags: stableTags?.isEmpty == false ? stableTags : nil + ) let metadata = BookmarkMetadata(bookmarkUri: uri, subject: subject, state: .unread) try await repository.applyWrites( in: repositoryDID, @@ -127,6 +157,73 @@ public extension SavedLibrary { return created } + @discardableResult + func setTags(ofBookmarkURI uri: String, to rawTags: [String]) async throws -> BookmarkView { + let tags = try BookmarkTags.normalized(rawTags) + guard let key = LexiconURI.recordKey(from: uri), + let current: RepositoryRecord = try await repository.record( + in: repositoryDID, + collection: .bookmark, + withKey: key + ), + current.uri == uri + else { + throw SavedLibraryError.bookmarkNotFound + } + + var next = current.value + next.tags = tags.isEmpty ? nil : tags + if next.tags != current.value.tags { + do { + try await repository.applyWrites(in: repositoryDID, writes: [ + try .updating(collection: .bookmark, key: key, value: next, swapRecord: current.cid), + ]) + } catch RepositoryClientError.conflict { + throw SavedLibraryError.conflict + } + } + + guard let updated: RepositoryRecord = try await repository.record( + in: repositoryDID, + collection: .bookmark, + withKey: key + ) else { + throw SavedLibraryError.bookmarkNotFound + } + return try await bookmarkView(for: updated) + } + + func renameTag( + _ rawSource: String, + to rawTarget: String, + limit: Int = 25, + continuingFrom cursor: String? = nil + ) async throws -> BookmarkTagMutationSummary { + let rename = try BookmarkTags.normalizedRename(source: rawSource, target: rawTarget) + return try await mutateTag( + rename.source, + limit: limit, + continuingFrom: cursor, + transform: { tags in + deduplicatedTags(tags.map { $0 == rename.source ? rename.target : $0 }) + } + ) + } + + func deleteTag( + _ rawTag: String, + limit: Int = 25, + continuingFrom cursor: String? = nil + ) async throws -> BookmarkTagMutationSummary { + let tag = try BookmarkTags.normalized(rawTag) + return try await mutateTag( + tag, + limit: limit, + continuingFrom: cursor, + transform: { tags in deduplicatedTags(tags.filter { $0 != tag }) } + ) + } + func setState(ofBookmarkURI uri: String, to state: SavedItemState) async throws { guard let key = LexiconURI.recordKey(from: uri) else { throw SavedLibraryError.bookmarkNotFound } let bookmark: RepositoryRecord? = try await repository.record( @@ -183,6 +280,102 @@ public extension SavedLibrary { return value } + private func bookmarkView(for bookmark: RepositoryRecord) async throws -> BookmarkView { + guard let key = LexiconURI.recordKey(from: bookmark.uri) else { + throw SavedLibraryError.invalidStoredRecord(uri: bookmark.uri) + } + let metadata: RepositoryRecord? = try await repository.record( + in: repositoryDID, + collection: .bookmarkMetadata, + withKey: key + ) + return BookmarkView( + record: bookmark, + metadataRecord: metadata.flatMap { bookmarkMetadata($0, matches: bookmark) ? $0 : nil } + ) + } + + private func mutateTag( + _ source: String, + limit: Int, + continuingFrom rawCursor: String?, + transform: ([String]) -> [String] + ) async throws -> BookmarkTagMutationSummary { + let cursor = try BookmarkTagMutationCursor(rawValue: rawCursor) + let page: RecordList = try await repository.listRecords( + in: repositoryDID, + collection: .bookmark, + limit: min(max(limit, 1), 25), + startingAfter: cursor.repositoryCursor + ) + let matches = page.records.filter { $0.value.tags?.contains(source) == true } + var writes: [RepositoryWrite] = [] + writes.reserveCapacity(matches.count) + + for record in matches { + guard let key = LexiconURI.recordKey(from: record.uri) else { + throw SavedLibraryError.invalidStoredRecord(uri: record.uri) + } + var next = record.value + let tags = transform(record.value.tags ?? []) + next.tags = tags.isEmpty ? nil : tags + writes.append(try .updating( + collection: .bookmark, + key: key, + value: next, + swapRecord: record.cid + )) + } + + if !writes.isEmpty { + do { + try await repository.applyWrites(in: repositoryDID, writes: writes) + } catch RepositoryClientError.conflict { + throw SavedLibraryError.conflict + } + } + + switch cursor { + case .mutation: + let nextCursor = page.cursor.map(BookmarkTagMutationCursor.mutation) + ?? .verification(nil) + return BookmarkTagMutationSummary( + scanned: page.records.count, + matched: matches.count, + updated: writes.count, + cursor: nextCursor.rawValue + ) + case .verification: + if !writes.isEmpty { + return BookmarkTagMutationSummary( + scanned: page.records.count, + matched: matches.count, + updated: writes.count, + cursor: BookmarkTagMutationCursor.verification(nil).rawValue + ) + } + if let next = page.cursor { + return BookmarkTagMutationSummary( + scanned: page.records.count, + matched: 0, + updated: 0, + cursor: BookmarkTagMutationCursor.verification(next).rawValue + ) + } + return BookmarkTagMutationSummary( + scanned: page.records.count, + matched: 0, + updated: 0, + cursor: nil + ) + } + } + + private func deduplicatedTags(_ tags: [String]) -> [String] { + var seen: Set = [] + return tags.filter { seen.insert($0).inserted } + } + private func canonicalBookmark(from records: [RepositoryRecord]) -> RepositoryRecord? { records.sorted { if $0.value.createdAt != $1.value.createdAt { return $0.value.createdAt < $1.value.createdAt } @@ -217,3 +410,39 @@ public extension SavedLibrary { metadata.value.bookmarkUri == bookmark.uri && metadata.value.subject == bookmark.value.subject } } + +private enum BookmarkTagMutationCursor { + case mutation(String?) + case verification(String?) + + init(rawValue: String?) throws { + guard let rawValue else { + self = .mutation(nil) + return + } + if rawValue.hasPrefix("m:") { + let value = String(rawValue.dropFirst(2)) + self = .mutation(value.isEmpty ? nil : value) + return + } + if rawValue.hasPrefix("v:") { + let value = String(rawValue.dropFirst(2)) + self = .verification(value.isEmpty ? nil : value) + return + } + throw SavedLibraryError.invalidTagMutationCursor + } + + var repositoryCursor: String? { + switch self { + case let .mutation(cursor), let .verification(cursor): cursor + } + } + + var rawValue: String { + switch self { + case let .mutation(cursor): "m:\(cursor ?? "")" + case let .verification(cursor): "v:\(cursor ?? "")" + } + } +} diff --git a/Sources/LatrKit/Library/SavedLibraryError.swift b/Sources/LatrKit/Library/SavedLibraryError.swift index 7f39e2f..61358df 100644 --- a/Sources/LatrKit/Library/SavedLibraryError.swift +++ b/Sources/LatrKit/Library/SavedLibraryError.swift @@ -6,4 +6,5 @@ public enum SavedLibraryError: Error, Sendable { case conflict case invalidStoredRecord(uri: String) case bookmarkNotFound + case invalidTagMutationCursor } diff --git a/Sources/LatrKit/Models/BookmarkTags.swift b/Sources/LatrKit/Models/BookmarkTags.swift new file mode 100644 index 0000000..698e2a4 --- /dev/null +++ b/Sources/LatrKit/Models/BookmarkTags.swift @@ -0,0 +1,110 @@ +import Foundation + +public enum BookmarkTagValidationError: Error, Sendable, Equatable { + case emptyTag + case tooManyTags(maximum: Int) + case exceedsGraphemeLimit(maximum: Int) + case exceedsUTF8Limit(maximum: Int) + case identicalRename +} + +public enum BookmarkTags { + public static let maximumCount = 100 + public static let maximumGraphemes = 64 + public static let maximumUTF8Bytes = 640 + + public static func normalized(_ rawTag: String) throws -> String { + let tag = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) + guard !tag.isEmpty else { throw BookmarkTagValidationError.emptyTag } + guard tag.count <= maximumGraphemes else { + throw BookmarkTagValidationError.exceedsGraphemeLimit(maximum: maximumGraphemes) + } + guard tag.utf8.count <= maximumUTF8Bytes else { + throw BookmarkTagValidationError.exceedsUTF8Limit(maximum: maximumUTF8Bytes) + } + return tag + } + + public static func normalized(_ rawTags: [String]) throws -> [String] { + guard rawTags.count <= maximumCount else { + throw BookmarkTagValidationError.tooManyTags(maximum: maximumCount) + } + var seen: Set = [] + var tags: [String] = [] + tags.reserveCapacity(min(rawTags.count, maximumCount)) + + for rawTag in rawTags { + let tag = try normalized(rawTag) + if seen.insert(tag).inserted { + tags.append(tag) + } + } + + return tags + } + + public static func merging(_ existingTags: [String], with addedTags: [String]) throws -> [String] { + let existing = try normalized(existingTags) + let added = try normalized(addedTags) + var seen = Set(existing) + var merged = existing + for tag in added where seen.insert(tag).inserted { + merged.append(tag) + } + guard merged.count <= maximumCount else { + throw BookmarkTagValidationError.tooManyTags(maximum: maximumCount) + } + return merged + } + + public static func normalizedRename(source rawSource: String, target rawTarget: String) throws -> (source: String, target: String) { + let source = try normalized(rawSource) + let target = try normalized(rawTarget) + guard source != target else { throw BookmarkTagValidationError.identicalRename } + return (source, target) + } +} + +public struct BookmarkTagCount: Codable, Sendable, Equatable { + public let tag: String + public let count: Int + + public init(tag: String, count: Int) { + self.tag = tag + self.count = count + } +} + +public struct BookmarkTagList: Codable, Sendable, Equatable { + public let tagCounts: [BookmarkTagCount] + public let scanned: Int + public let cursor: String? + + public init(tagCounts: [BookmarkTagCount], scanned: Int, cursor: String?) { + self.tagCounts = tagCounts + self.scanned = scanned + self.cursor = cursor + } +} + +public struct BookmarkTagMutationSummary: Codable, Sendable, Equatable { + public let ok: Bool + public let scanned: Int + public let matched: Int + public let updated: Int + public let cursor: String? + + public init( + ok: Bool = true, + scanned: Int, + matched: Int, + updated: Int, + cursor: String? + ) { + self.ok = ok + self.scanned = scanned + self.matched = matched + self.updated = updated + self.cursor = cursor + } +} diff --git a/Sources/LatrKit/XRPC/LatrXRPCContracts.swift b/Sources/LatrKit/XRPC/LatrXRPCContracts.swift index 27bc79f..4346db9 100644 --- a/Sources/LatrKit/XRPC/LatrXRPCContracts.swift +++ b/Sources/LatrKit/XRPC/LatrXRPCContracts.swift @@ -9,12 +9,19 @@ public struct LatrSetStateInput: Codable, Sendable, Equatable { public let itemR public struct LatrDeleteItemInput: Codable, Sendable, Equatable { public let itemRkey: String; public init(itemRkey: String) { self.itemRkey = itemRkey } } public struct LatrSimpleOK: Codable, Sendable, Equatable { public let ok: Bool; public init(ok: Bool) { self.ok = ok } } public struct LatrSaveResult: Codable, Sendable, Equatable { public let ok: Bool; public let kind: String; public let subjectUri: String?; public let linkedWebUrl: String?; public let storage: String? } -public struct LatrListBookmarksParameters: Codable, Sendable, Equatable { public let limit: Int?; public let cursor: String?; public init(limit: Int? = nil, cursor: String? = nil) { self.limit = limit; self.cursor = cursor } } +public struct LatrListBookmarksParameters: Codable, Sendable, Equatable { public let limit: Int?; public let cursor: String?; public let tag: String?; public init(limit: Int? = nil, cursor: String? = nil, tag: String? = nil) { self.limit = limit; self.cursor = cursor; self.tag = tag } } public struct LatrListBookmarksOutput: Codable, Sendable { public let bookmarks: [BookmarkView]; public let cursor: String?; public init(bookmarks: [BookmarkView], cursor: String?) { self.bookmarks = bookmarks; self.cursor = cursor } } +public struct LatrListTagsParameters: Codable, Sendable, Equatable { public let limit: Int?; public let cursor: String?; public init(limit: Int? = nil, cursor: String? = nil) { self.limit = limit; self.cursor = cursor } } +public typealias LatrListTagsOutput = BookmarkTagList +public typealias LatrTagCount = BookmarkTagCount +public typealias LatrTagMutationResult = BookmarkTagMutationSummary public struct LatrGetBookmarkOutput: Codable, Sendable { public let bookmark: BookmarkView?; public init(bookmark: BookmarkView?) { self.bookmark = bookmark } } public struct LatrSaveBookmarkInput: Codable, Sendable, Equatable { public let subject: String; public let tags: [String]?; public init(subject: String, tags: [String]? = nil) { self.subject = subject; self.tags = tags } } public struct LatrSyncBookmarkMetadataInput: Codable, Sendable, Equatable { public let limit: Int?; public let cursor: String?; public init(limit: Int? = nil, cursor: String? = nil) { self.limit = limit; self.cursor = cursor } } public struct LatrSetBookmarkStateInput: Codable, Sendable, Equatable { public let bookmarkUri: String; public let state: SavedItemState; public init(bookmarkUri: String, state: SavedItemState) { self.bookmarkUri = bookmarkUri; self.state = state } } +public struct LatrSetBookmarkTagsInput: Codable, Sendable, Equatable { public let bookmarkUri: String; public let tags: [String]; public init(bookmarkUri: String, tags: [String]) { self.bookmarkUri = bookmarkUri; self.tags = tags } } +public struct LatrRenameBookmarkTagInput: Codable, Sendable, Equatable { public let tag: String; public let replacement: String; public let limit: Int?; public let cursor: String?; public init(tag: String, replacement: String, limit: Int? = nil, cursor: String? = nil) { self.tag = tag; self.replacement = replacement; self.limit = limit; self.cursor = cursor } } +public struct LatrDeleteBookmarkTagInput: Codable, Sendable, Equatable { public let tag: String; public let limit: Int?; public let cursor: String?; public init(tag: String, limit: Int? = nil, cursor: String? = nil) { self.tag = tag; self.limit = limit; self.cursor = cursor } } public struct LatrDeleteBookmarkInput: Codable, Sendable, Equatable { public let bookmarkUri: String; public init(bookmarkUri: String) { self.bookmarkUri = bookmarkUri } } public struct LatrMigrateBookmarksInput: Codable, Sendable, Equatable { public let limit: Int?; public let cursor: String?; public init(limit: Int? = nil, cursor: String? = nil) { self.limit = limit; self.cursor = cursor } } public struct LatrBookmarkMigrationResult: Codable, Sendable, Equatable { @@ -36,6 +43,12 @@ public enum LatrPayloadValidator { if value.hasPrefix("at://") { try validateATURI(value); return } guard let scheme = URLComponents(string: value)?.scheme?.lowercased(), ["http", "https"].contains(scheme) else { throw LatrPayloadValidationError.invalidURL } } + public static func validateTags(_ values: [String]) throws -> [String] { + try BookmarkTags.normalized(values) + } + public static func validateTag(_ value: String) throws -> String { + try BookmarkTags.normalized(value) + } } public struct LatrXRPCClient: Sendable { @@ -46,15 +59,28 @@ public struct LatrXRPCClient: Sendable { var query: [URLQueryItem] = [] if let limit = parameters.limit { query.append(URLQueryItem(name: "limit", value: String(limit))) } if let cursor = parameters.cursor { query.append(URLQueryItem(name: "cursor", value: cursor)) } + if let tag = parameters.tag { query.append(URLQueryItem(name: "tag", value: try LatrPayloadValidator.validateTag(tag))) } return try JSONDecoder().decode(LatrListBookmarksOutput.self, from: try await transport.send(method: .listBookmarks, parameters: query, body: nil)) } + public func listTags(_ parameters: LatrListTagsParameters = .init()) async throws -> LatrListTagsOutput { + if let limit = parameters.limit, !(1 ... 100).contains(limit) { throw LatrPayloadValidationError.invalidLimit } + var query: [URLQueryItem] = [] + if let limit = parameters.limit { query.append(URLQueryItem(name: "limit", value: String(limit))) } + if let cursor = parameters.cursor { query.append(URLQueryItem(name: "cursor", value: cursor)) } + return try JSONDecoder().decode(LatrListTagsOutput.self, from: try await transport.send(method: .listTags, parameters: query, body: nil)) + } public func getBookmark(subject: String) async throws -> LatrGetBookmarkOutput { try LatrPayloadValidator.validateSubject(subject) return try JSONDecoder().decode(LatrGetBookmarkOutput.self, from: try await transport.send(method: .getBookmark, parameters: [URLQueryItem(name: "subject", value: subject)], body: nil)) } public func saveBookmark(_ input: LatrSaveBookmarkInput) async throws -> BookmarkView { try LatrPayloadValidator.validateSubject(input.subject) - return try await procedure(.saveBookmark, input, as: BookmarkView.self) + let normalized = try input.tags.map(LatrPayloadValidator.validateTags) + return try await procedure( + .saveBookmark, + LatrSaveBookmarkInput(subject: input.subject, tags: normalized), + as: BookmarkView.self + ) } public func syncBookmarkMetadata(_ input: LatrSyncBookmarkMetadataInput = .init()) async throws -> BookmarkMetadataSyncSummary { if let limit = input.limit, !(1 ... 100).contains(limit) { throw LatrPayloadValidationError.invalidLimit } @@ -64,6 +90,33 @@ public struct LatrXRPCClient: Sendable { try LatrPayloadValidator.validateATURI(input.bookmarkUri) return try await procedure(.setBookmarkState, input, as: LatrSimpleOK.self) } + public func setBookmarkTags(_ input: LatrSetBookmarkTagsInput) async throws -> BookmarkView { + try LatrPayloadValidator.validateATURI(input.bookmarkUri) + let tags = try LatrPayloadValidator.validateTags(input.tags) + return try await procedure( + .setBookmarkTags, + LatrSetBookmarkTagsInput(bookmarkUri: input.bookmarkUri, tags: tags), + as: BookmarkView.self + ) + } + public func renameBookmarkTag(_ input: LatrRenameBookmarkTagInput) async throws -> BookmarkTagMutationSummary { + if let limit = input.limit, !(1 ... 25).contains(limit) { throw LatrPayloadValidationError.invalidLimit } + let rename = try BookmarkTags.normalizedRename(source: input.tag, target: input.replacement) + return try await procedure( + .renameBookmarkTag, + LatrRenameBookmarkTagInput(tag: rename.source, replacement: rename.target, limit: input.limit, cursor: input.cursor), + as: BookmarkTagMutationSummary.self + ) + } + public func deleteBookmarkTag(_ input: LatrDeleteBookmarkTagInput) async throws -> BookmarkTagMutationSummary { + if let limit = input.limit, !(1 ... 25).contains(limit) { throw LatrPayloadValidationError.invalidLimit } + let tag = try LatrPayloadValidator.validateTag(input.tag) + return try await procedure( + .deleteBookmarkTag, + LatrDeleteBookmarkTagInput(tag: tag, limit: input.limit, cursor: input.cursor), + as: BookmarkTagMutationSummary.self + ) + } public func deleteBookmark(_ input: LatrDeleteBookmarkInput) async throws -> LatrSimpleOK { try LatrPayloadValidator.validateATURI(input.bookmarkUri) return try await procedure(.deleteBookmark, input, as: LatrSimpleOK.self) diff --git a/Sources/LatrKit/XRPC/LatrXRPCMethod.swift b/Sources/LatrKit/XRPC/LatrXRPCMethod.swift index 2715e8a..416b412 100644 --- a/Sources/LatrKit/XRPC/LatrXRPCMethod.swift +++ b/Sources/LatrKit/XRPC/LatrXRPCMethod.swift @@ -8,10 +8,14 @@ public struct LatrXRPCMethod: Hashable, Sendable { public var verb: String { kind == .query ? "GET" : "POST" } public static let listBookmarks = Self("link.latr.bookmarks.listBookmarks", .query) + public static let listTags = Self("link.latr.bookmarks.listTags", .query) public static let getBookmark = Self("link.latr.bookmarks.getBookmark", .query) public static let saveBookmark = Self("link.latr.bookmarks.saveBookmark", .procedure) public static let syncBookmarkMetadata = Self("link.latr.bookmarks.syncMetadata", .procedure) public static let setBookmarkState = Self("link.latr.bookmarks.setState", .procedure) + public static let setBookmarkTags = Self("link.latr.bookmarks.setTags", .procedure) + public static let renameBookmarkTag = Self("link.latr.bookmarks.renameTag", .procedure) + public static let deleteBookmarkTag = Self("link.latr.bookmarks.deleteTag", .procedure) public static let deleteBookmark = Self("link.latr.bookmarks.deleteBookmark", .procedure) public static let migrateBookmarks = Self("link.latr.bookmarks.migrateLegacy", .procedure) @@ -32,7 +36,7 @@ public struct LatrXRPCMethod: Hashable, Sendable { public static let createKey = Self("link.latr.developer.createKey", .procedure, false) public static let revokeKey = Self("link.latr.developer.revokeKey", .procedure, false) public static let getUsage = Self("link.latr.developer.getUsage", .query, false) - public static let all: [Self] = [.listBookmarks, .getBookmark, .saveBookmark, .syncBookmarkMetadata, .setBookmarkState, .deleteBookmark, .migrateBookmarks, .listItems, .getItem, .saveURL, .saveSubject, .setState, .deleteItem, .migrateLegacy, .getOpenGraph, .resolveURL, .authProbe, .listClients, .createClient, .deleteClient, .listKeys, .createKey, .revokeKey, .getUsage] + public static let all: [Self] = [.listBookmarks, .listTags, .getBookmark, .saveBookmark, .syncBookmarkMetadata, .setBookmarkState, .setBookmarkTags, .renameBookmarkTag, .deleteBookmarkTag, .deleteBookmark, .migrateBookmarks, .listItems, .getItem, .saveURL, .saveSubject, .setState, .deleteItem, .migrateLegacy, .getOpenGraph, .resolveURL, .authProbe, .listClients, .createClient, .deleteClient, .listKeys, .createKey, .revokeKey, .getUsage] private init(_ nsid: String, _ kind: Kind, _ requiresApplicationCredential: Bool = true) { self.nsid = nsid; self.kind = kind; self.requiresApplicationCredential = requiresApplicationCredential diff --git a/Tests/LatrKitTests/BookmarkLibraryTests.swift b/Tests/LatrKitTests/BookmarkLibraryTests.swift index f2eee80..9dee412 100644 --- a/Tests/LatrKitTests/BookmarkLibraryTests.swift +++ b/Tests/LatrKitTests/BookmarkLibraryTests.swift @@ -19,7 +19,7 @@ final class BookmarkLibraryTests: XCTestCase { XCTAssertEqual(first.uri, second.uri) XCTAssertEqual(second.value.subject, subject) - XCTAssertEqual(second.value.tags, ["later", "news"]) + XCTAssertEqual(second.value.tags, ["news", "later"]) XCTAssertEqual(second.metadataRecord?.value.state, .unread) XCTAssertEqual(repository.snapshotKeys().filter { $0.hasPrefix("\(LexiconCollection.bookmark.identifier):") }.count, 1) } @@ -152,6 +152,369 @@ final class BookmarkLibraryTests: XCTestCase { XCTAssertEqual(retry.reused, 1) } + func testTagNormalizationPreservesCaseAndInternalSpaces() throws { + XCTAssertEqual( + try BookmarkTags.normalized([" Swift ", "Swift", "swift", "Design Systems"]), + ["Swift", "swift", "Design Systems"] + ) + XCTAssertThrowsError(try BookmarkTags.normalized([" \n "])) { error in + XCTAssertEqual(error as? BookmarkTagValidationError, .emptyTag) + } + XCTAssertThrowsError(try BookmarkTags.normalized(Array(repeating: "same", count: 101))) { error in + XCTAssertEqual(error as? BookmarkTagValidationError, .tooManyTags(maximum: 100)) + } + XCTAssertThrowsError(try BookmarkTags.normalized(String(repeating: "a", count: 65))) { error in + XCTAssertEqual(error as? BookmarkTagValidationError, .exceedsGraphemeLimit(maximum: 64)) + } + let byteHeavy = String(repeating: "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", count: 26) + XCTAssertLessThanOrEqual(byteHeavy.count, 64) + XCTAssertGreaterThan(byteHeavy.utf8.count, 640) + XCTAssertThrowsError(try BookmarkTags.normalized(byteHeavy)) { error in + XCTAssertEqual(error as? BookmarkTagValidationError, .exceedsUTF8Limit(maximum: 640)) + } + XCTAssertThrowsError(try BookmarkTags.normalizedRename(source: "News", target: " News ")) { error in + XCTAssertEqual(error as? BookmarkTagValidationError, .identicalRename) + } + + let full = (0 ..< 100).map { "tag-\($0)" } + XCTAssertEqual(try BookmarkTags.merging(full, with: ["tag-99"]), full) + XCTAssertThrowsError(try BookmarkTags.merging(full, with: ["tag-100"])) { error in + XCTAssertEqual(error as? BookmarkTagValidationError, .tooManyTags(maximum: 100)) + } + } + + func testFilteredBookmarkPageKeepsSourceCursorWhenNoRowsMatch() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + for (key, tags) in [("a", ["News"]), ("b", ["news"]), ("c", ["News"])] { + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: key, + value: CommunityBookmark( + subject: "https://example.com/\(key)", + createdAt: "2026-01-01T00:00:00Z", + tags: tags + ) + ) + } + + let empty = try await library.bookmarks(limit: 1, startingAfter: "1", taggedWith: "News") + XCTAssertTrue(empty.records.isEmpty) + XCTAssertEqual(empty.cursor, "2") + + let next = try await library.bookmarks(limit: 1, startingAfter: empty.cursor, taggedWith: " News ") + XCTAssertEqual(next.records.map(\.value.subject), ["https://example.com/c"]) + XCTAssertNil(next.cursor) + } + + func testBookmarkTagsReturnsExactPerPageBookmarkCounts() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let fixtures: [(String, [String]?)] = [ + ("a", ["News", "News", "Swift"]), + ("b", ["News", "news"]), + ("c", nil), + ] + for (key, tags) in fixtures { + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: key, + value: CommunityBookmark( + subject: "https://example.com/\(key)", + createdAt: "2026-01-01T00:00:00Z", + tags: tags + ) + ) + } + + let first = try await library.bookmarkTags(limit: 2) + XCTAssertEqual(first.scanned, 2) + XCTAssertEqual(first.tagCounts, [ + BookmarkTagCount(tag: "News", count: 2), + BookmarkTagCount(tag: "Swift", count: 1), + BookmarkTagCount(tag: "news", count: 1), + ]) + XCTAssertEqual(first.cursor, "2") + + let second = try await library.bookmarkTags(limit: 2, startingAfter: first.cursor) + XCTAssertEqual(second.scanned, 1) + XCTAssertTrue(second.tagCounts.isEmpty) + XCTAssertNil(second.cursor) + } + + func testBookmarkTagsClampsAndPaginatesBeyondFiftyRecords() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + for index in 0 ..< 55 { + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: String(format: "page-%02d", index), + value: CommunityBookmark( + subject: "https://example.com/\(index)", + createdAt: "2026-01-01T00:00:00Z", + tags: ["all", index.isMultiple(of: 2) ? "even" : "odd"] + ) + ) + } + + var cursor: String? + var scannedPages: [Int] = [] + var totalCounts: [String: Int] = [:] + repeat { + let page = try await library.bookmarkTags(limit: 40, startingAfter: cursor) + scannedPages.append(page.scanned) + for tag in page.tagCounts { totalCounts[tag.tag, default: 0] += tag.count } + cursor = page.cursor + } while cursor != nil + + XCTAssertEqual(scannedPages, [40, 15]) + XCTAssertEqual(totalCounts, ["all": 55, "even": 28, "odd": 27]) + } + + func testSetTagsReplacesClearsAndPreservesUnknownFields() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let key = "tagged" + let uri = "at://\(did)/\(LexiconCollection.bookmark.identifier)/\(key)" + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: key, + value: CommunityBookmark( + subject: "https://example.com/tagged", + createdAt: "2026-01-01T00:00:00Z", + tags: ["old"], + unknownFields: ["future": .string("preserve")] + ) + ) + _ = try await repository.createRecord( + in: did, + collection: .bookmarkMetadata, + withKey: key, + value: BookmarkMetadata(bookmarkUri: uri, subject: "https://example.com/tagged", state: .archived) + ) + + let replaced = try await library.setTags(ofBookmarkURI: uri, to: [" Swift ", "swift", "Swift"]) + XCTAssertEqual(replaced.value.tags, ["Swift", "swift"]) + XCTAssertEqual(replaced.value.unknownFields["future"], .string("preserve")) + XCTAssertEqual(replaced.metadataRecord?.value.state, .archived) + + let cleared = try await library.setTags(ofBookmarkURI: uri, to: []) + XCTAssertNil(cleared.value.tags) + XCTAssertEqual(cleared.value.unknownFields["future"], .string("preserve")) + } + + func testSetTagsConflictIsRetryableWithoutLosingConcurrentFields() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let saved = try await library.saveBookmark(subject: "https://example.com/conflict", tags: ["old"]) + let key = try XCTUnwrap(LexiconURI.recordKey(from: saved.uri)) + let repositoryDID = did + repository.beforeNextApplyWrites { _ in + var concurrent = saved.value + concurrent.unknownFields["concurrent"] = .boolean(true) + _ = try await repository.updateRecord( + in: repositoryDID, + collection: .bookmark, + withKey: key, + value: concurrent, + swapRecord: saved.cid + ) + } + + do { + _ = try await library.setTags(ofBookmarkURI: saved.uri, to: ["new"]) + XCTFail("Expected CID conflict") + } catch SavedLibraryError.conflict {} + + let retried = try await library.setTags(ofBookmarkURI: saved.uri, to: ["new"]) + XCTAssertEqual(retried.value.tags, ["new"]) + XCTAssertEqual(retried.value.unknownFields["concurrent"], .boolean(true)) + } + + func testRenameTagUsesBoundedCIDGuardedBatchesAndVerification() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + for index in 0 ..< 30 { + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: String(format: "tag-%02d", index), + value: CommunityBookmark( + subject: "https://example.com/\(index)", + createdAt: "2026-01-01T00:00:00Z", + tags: ["source", "target", "other"], + unknownFields: index == 0 ? ["future": .string("preserve")] : [:] + ) + ) + } + repository.resetAppliedWriteBatches() + + let first = try await library.renameTag(" source ", to: "target") + XCTAssertEqual(first.scanned, 25) + XCTAssertEqual(first.updated, 25) + XCTAssertEqual(first.cursor, "m:25") + + let second = try await library.renameTag("source", to: "target", continuingFrom: first.cursor) + XCTAssertEqual(second.scanned, 5) + XCTAssertEqual(second.updated, 5) + XCTAssertEqual(second.cursor, "v:") + + let verifyFirst = try await library.renameTag("source", to: "target", continuingFrom: second.cursor) + XCTAssertEqual(verifyFirst.scanned, 25) + XCTAssertEqual(verifyFirst.cursor, "v:25") + + let done = try await library.renameTag("source", to: "target", continuingFrom: verifyFirst.cursor) + XCTAssertNil(done.cursor) + XCTAssertEqual(repository.appliedWriteBatches.map(\.count), [25, 5]) + + for index in 0 ..< 30 { + let record: RepositoryRecord? = try await repository.record( + in: did, + collection: .bookmark, + withKey: String(format: "tag-%02d", index) + ) + XCTAssertEqual(record?.value.tags, ["target", "other"]) + if index == 0 { + XCTAssertEqual(record?.value.unknownFields["future"], .string("preserve")) + } + } + } + + func testDeleteTagVerificationRestartsWhenSourceReappears() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: "a", + value: CommunityBookmark(subject: "https://example.com/a", createdAt: "2026-01-01T00:00:00Z", tags: ["source"]) + ) + + let mutation = try await library.deleteTag("source") + XCTAssertEqual(mutation.cursor, "v:") + let currentRecord: RepositoryRecord? = try await repository.record( + in: did, + collection: .bookmark, + withKey: "a" + ) + let current = try XCTUnwrap(currentRecord) + var reintroduced = current.value + reintroduced.tags = ["source", "other"] + _ = try await repository.updateRecord( + in: did, + collection: .bookmark, + withKey: "a", + value: reintroduced, + swapRecord: current.cid + ) + + let verification = try await library.deleteTag("source", continuingFrom: mutation.cursor) + XCTAssertEqual(verification.updated, 1) + XCTAssertEqual(verification.cursor, "v:") + + let done = try await library.deleteTag("source", continuingFrom: verification.cursor) + XCTAssertNil(done.cursor) + let finalRecord: RepositoryRecord? = try await repository.record( + in: did, + collection: .bookmark, + withKey: "a" + ) + let final = try XCTUnwrap(finalRecord) + XCTAssertEqual(final.value.tags, ["other"]) + } + + func testTagMutationHonorsRequestedBatchLimit() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + for key in ["a", "b", "c"] { + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: key, + value: CommunityBookmark( + subject: "https://example.com/\(key)", + createdAt: "2026-01-01T00:00:00Z", + tags: ["source"] + ) + ) + } + + let first = try await library.deleteTag("source", limit: 2) + + XCTAssertEqual(first.scanned, 2) + XCTAssertEqual(first.matched, 2) + XCTAssertEqual(first.updated, 2) + XCTAssertEqual(first.cursor, "m:2") + XCTAssertEqual(repository.appliedWriteBatches.map(\.count), [2]) + } + + func testTagBatchConflictCommitsNoLibraryWritesAndSameCursorRetries() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + for key in ["a", "b"] { + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: key, + value: CommunityBookmark( + subject: "https://example.com/\(key)", + createdAt: "2026-01-01T00:00:00Z", + tags: ["source"] + ) + ) + } + let concurrentRecord: RepositoryRecord? = try await repository.record( + in: did, + collection: .bookmark, + withKey: "b" + ) + let concurrent = try XCTUnwrap(concurrentRecord) + let repositoryDID = did + repository.resetAppliedWriteBatches() + repository.beforeNextApplyWrites { _ in + var value = concurrent.value + value.unknownFields["concurrent"] = .boolean(true) + _ = try await repository.updateRecord( + in: repositoryDID, + collection: .bookmark, + withKey: "b", + value: value, + swapRecord: concurrent.cid + ) + } + + do { + _ = try await library.renameTag("source", to: "target") + XCTFail("Expected CID conflict") + } catch SavedLibraryError.conflict {} + XCTAssertTrue(repository.appliedWriteBatches.isEmpty) + + let unchangedRecord: RepositoryRecord? = try await repository.record( + in: did, + collection: .bookmark, + withKey: "a" + ) + let unchanged = try XCTUnwrap(unchangedRecord) + XCTAssertEqual(unchanged.value.tags, ["source"]) + + let retry = try await library.renameTag("source", to: "target") + XCTAssertEqual(retry.updated, 2) + } + + func testTagMutationRejectsUnknownCursor() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + + do { + _ = try await library.deleteTag("source", continuingFrom: "not-a-tag-cursor") + XCTFail("Expected invalid cursor") + } catch SavedLibraryError.invalidTagMutationCursor {} + } + func testMigrationFlattensWrapperPreservesStateAndIsRetrySafe() async throws { let repository = InMemoryRepository() let library = SavedLibrary(repository: repository, repositoryDID: did) diff --git a/Tests/LatrKitTests/InMemoryRepository.swift b/Tests/LatrKitTests/InMemoryRepository.swift index 6fb26be..b5dec40 100644 --- a/Tests/LatrKitTests/InMemoryRepository.swift +++ b/Tests/LatrKitTests/InMemoryRepository.swift @@ -4,13 +4,21 @@ import LatrKit final class InMemoryRepository: RepositoryClient, @unchecked Sendable { private var store: [String: (uri: String, cid: String, json: Data)] = [:] private var beforeNextApplyWrites: (@Sendable ([RepositoryWrite]) async throws -> Void)? + private var cidCounter = 0 + private(set) var appliedWriteBatches: [[RepositoryWrite]] = [] func snapshotKeys() -> [String] { Array(store.keys) } + func resetAppliedWriteBatches() { appliedWriteBatches = [] } private func storeKey(collection: LexiconCollection, key: String) -> String { "\(collection.identifier):\(key)" } + private func nextCID() -> String { + cidCounter += 1 + return "bafytest\(cidCounter)" + } + func listRecords( in repository: String, collection: LexiconCollection, @@ -51,7 +59,7 @@ final class InMemoryRepository: RepositoryClient, @unchecked Sendable { ) async throws -> CreateRecordResponse { let uri = "at://\(repository)/\(collection.identifier)/\(key)" let json = try JSONEncoder().encode(value) - store[storeKey(collection: collection, key: key)] = (uri: uri, cid: "bafytest", json: json) + store[storeKey(collection: collection, key: key)] = (uri: uri, cid: nextCID(), json: json) return CreateRecordResponse(uri: uri) } @@ -64,7 +72,11 @@ final class InMemoryRepository: RepositoryClient, @unchecked Sendable { ) async throws -> UpdateRecordResponse { let uri = "at://\(repository)/\(collection.identifier)/\(key)" let json = try JSONEncoder().encode(value) - store[storeKey(collection: collection, key: key)] = (uri: uri, cid: "bafytest", json: json) + let storageKey = storeKey(collection: collection, key: key) + if let swapRecord, store[storageKey]?.cid != swapRecord { + throw RepositoryClientError.conflict + } + store[storageKey] = (uri: uri, cid: nextCID(), json: json) return UpdateRecordResponse(uri: uri) } @@ -89,12 +101,12 @@ final class InMemoryRepository: RepositoryClient, @unchecked Sendable { let storeKey = storeKey(collection: collection, key: key) guard next[storeKey] == nil else { throw RepositoryClientError.conflict } let uri = "at://\(repository)/\(collection.identifier)/\(key)" - next[storeKey] = (uri, "bafytest", try JSONEncoder().encode(value)) + next[storeKey] = (uri, nextCID(), try JSONEncoder().encode(value)) case let .update(collection, key, value, swapRecord): let storeKey = storeKey(collection: collection, key: key) guard next[storeKey]?.cid == swapRecord else { throw RepositoryClientError.conflict } let uri = "at://\(repository)/\(collection.identifier)/\(key)" - next[storeKey] = (uri, "bafyupdated", try JSONEncoder().encode(value)) + next[storeKey] = (uri, nextCID(), try JSONEncoder().encode(value)) case let .delete(collection, key, swapRecord): let storeKey = storeKey(collection: collection, key: key) if let swapRecord, next[storeKey]?.cid != swapRecord { throw RepositoryClientError.conflict } @@ -102,6 +114,7 @@ final class InMemoryRepository: RepositoryClient, @unchecked Sendable { } } store = next + appliedWriteBatches.append(writes) } func hasRecord(collection: LexiconCollection, key: String) -> Bool { diff --git a/Tests/LatrKitTests/XRPCContractTests.swift b/Tests/LatrKitTests/XRPCContractTests.swift index 2dceb84..ef2fc29 100644 --- a/Tests/LatrKitTests/XRPCContractTests.swift +++ b/Tests/LatrKitTests/XRPCContractTests.swift @@ -3,16 +3,96 @@ import Testing @testable import LatrKit @Test func xrpcDescriptorsHaveStableVerbsAndCredentialPolicy() { - #expect(LatrXRPCMethod.all.count == 24) + #expect(LatrXRPCMethod.all.count == 28) #expect(LatrXRPCMethod.listBookmarks.verb == "GET") + #expect(LatrXRPCMethod.listTags.nsid == "link.latr.bookmarks.listTags") + #expect(LatrXRPCMethod.listTags.verb == "GET") #expect(LatrXRPCMethod.saveBookmark.nsid == "link.latr.bookmarks.saveBookmark") #expect(LatrXRPCMethod.syncBookmarkMetadata.nsid == "link.latr.bookmarks.syncMetadata") #expect(LatrXRPCMethod.syncBookmarkMetadata.verb == "POST") + #expect(LatrXRPCMethod.setBookmarkTags.nsid == "link.latr.bookmarks.setTags") + #expect(LatrXRPCMethod.renameBookmarkTag.nsid == "link.latr.bookmarks.renameTag") + #expect(LatrXRPCMethod.deleteBookmarkTag.nsid == "link.latr.bookmarks.deleteTag") #expect(LatrXRPCMethod.listItems.verb == "GET") #expect(LatrXRPCMethod.saveURL.verb == "POST") #expect(!LatrXRPCMethod.listClients.requiresApplicationCredential) } +@Test func tagXRPCClientNormalizesAndEncodesPublicContracts() async throws { + let bookmarkJSON = #"{"uri":"at://did:plc:test/community.lexicon.bookmarks.bookmark/3abc","cid":"bafybookmark","value":{"$type":"community.lexicon.bookmarks.bookmark","subject":"https://example.com/story","createdAt":"2026-08-13T00:00:00Z","tags":["Swift"]}}"# + let mutationJSON = #"{"ok":true,"scanned":25,"matched":2,"updated":2,"cursor":"m:next"}"# + let transport = RecordingXRPCTransport(responses: [ + LatrXRPCMethod.listBookmarks.nsid: Data(#"{"bookmarks":[],"cursor":"next"}"#.utf8), + LatrXRPCMethod.listTags.nsid: Data(#"{"tagCounts":[{"tag":"Swift","count":2}],"scanned":2}"#.utf8), + LatrXRPCMethod.setBookmarkTags.nsid: Data(bookmarkJSON.utf8), + LatrXRPCMethod.renameBookmarkTag.nsid: Data(mutationJSON.utf8), + LatrXRPCMethod.deleteBookmarkTag.nsid: Data(mutationJSON.utf8), + ]) + let client = LatrXRPCClient(transport: transport) + let bookmarkURI = "at://did:plc:test/community.lexicon.bookmarks.bookmark/3abc" + + _ = try await client.listBookmarks(.init(limit: 50, cursor: "page", tag: " Design Systems ")) + let tags = try await client.listTags(.init(limit: 25, cursor: "tags-page")) + _ = try await client.setBookmarkTags(.init(bookmarkUri: bookmarkURI, tags: [" Swift ", "Swift", "swift"])) + _ = try await client.renameBookmarkTag(.init(tag: " Swift ", replacement: "Language", limit: 25, cursor: "m:25")) + _ = try await client.deleteBookmarkTag(.init(tag: " Language ", limit: 25, cursor: "v:")) + + let calls = await transport.recordedCalls() + #expect(calls.count == 5) + #expect(calls[0].method == .listBookmarks) + #expect(calls[0].parameters == [ + URLQueryItem(name: "limit", value: "50"), + URLQueryItem(name: "cursor", value: "page"), + URLQueryItem(name: "tag", value: "Design Systems"), + ]) + #expect(calls[1].method == .listTags) + #expect(calls[1].parameters == [ + URLQueryItem(name: "limit", value: "25"), + URLQueryItem(name: "cursor", value: "tags-page"), + ]) + #expect(tags.tagCounts == [BookmarkTagCount(tag: "Swift", count: 2)]) + #expect(tags.scanned == 2) + + let setBody = try #require(calls[2].body) + let setInput = try JSONDecoder().decode(LatrSetBookmarkTagsInput.self, from: setBody) + #expect(setInput.tags == ["Swift", "swift"]) + let renameBody = try #require(calls[3].body) + let renameInput = try JSONDecoder().decode(LatrRenameBookmarkTagInput.self, from: renameBody) + #expect(renameInput == .init(tag: "Swift", replacement: "Language", limit: 25, cursor: "m:25")) + let deleteBody = try #require(calls[4].body) + let deleteInput = try JSONDecoder().decode(LatrDeleteBookmarkTagInput.self, from: deleteBody) + #expect(deleteInput == .init(tag: "Language", limit: 25, cursor: "v:")) +} + +@Test func tagMutationResultEncodesOnlyCanonicalProgressFields() throws { + let result = BookmarkTagMutationSummary( + scanned: 25, + matched: 2, + updated: 2, + cursor: "v:next" + ) + + let object = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(result)) as? [String: Any] + ) + #expect(Set(object.keys) == ["ok", "scanned", "matched", "updated", "cursor"]) +} + +@Test func tagXRPCClientEnforcesCanonicalOperationLimits() async throws { + let transport = RecordingXRPCTransport(responses: [:]) + let client = LatrXRPCClient(transport: transport) + + await #expect(throws: LatrPayloadValidationError.invalidLimit) { + _ = try await client.listTags(.init(limit: 101)) + } + await #expect(throws: LatrPayloadValidationError.invalidLimit) { + _ = try await client.renameBookmarkTag(.init(tag: "source", replacement: "target", limit: 26)) + } + await #expect(throws: LatrPayloadValidationError.invalidLimit) { + _ = try await client.deleteBookmarkTag(.init(tag: "source", limit: 0)) + } +} + @Test func communityBookmarkAndMetadataPreserveUnknownFields() throws { let bookmarkData = Data(#"{"$type":"community.lexicon.bookmarks.bookmark","subject":"https://example.com","createdAt":"2026-08-13T00:00:00Z","future":true}"#.utf8) let metadataData = Data(#"{"$type":"link.latr.bookmarks.metadata","bookmarkUri":"at://did:plc:test/community.lexicon.bookmarks.bookmark/3abc","subject":"https://example.com","state":"unread","future":{"version":2}}"#.utf8) @@ -52,3 +132,27 @@ import Testing try LatrPayloadValidator.validateURL(oversized) } } + +private actor RecordingXRPCTransport: LatrXRPCTransport { + struct Call: Sendable { + let method: LatrXRPCMethod + let parameters: [URLQueryItem] + let body: Data? + } + + private let responses: [String: Data] + private var calls: [Call] = [] + + init(responses: [String: Data]) { + self.responses = responses + } + + func send(method: LatrXRPCMethod, parameters: [URLQueryItem], body: Data?) async throws -> Data { + calls.append(Call(method: method, parameters: parameters, body: body)) + return responses[method.nsid] ?? Data() + } + + func recordedCalls() -> [Call] { + calls + } +}