diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index d9f5c9a..a3420db 100644 --- a/App/Composition/AppEnvironment.swift +++ b/App/Composition/AppEnvironment.swift @@ -189,6 +189,25 @@ final class AppEnvironment: ObservableObject { /// native OAuth flow. Exposed as the protocol so test doubles substitute in. let github: GitHubServicing + /// Synced app settings + the per-machine device registry + /// (work-consolidation.md G17) — the platform's own mechanism for companion + /// apps, and the sanctioned home for this app's preferences and the + /// Document Sync Agent's per-machine configuration. Optional because it is + /// only usable once an `appKey` is registered with the backend owner; the + /// Settings panes render an explicit unavailable state while it is nil. + let appSettings: AppSettingsServicing? + + /// The server-driven notification-preferences catalogue + /// (work-consolidation.md G18) that Settings ▸ Notifications renders itself from. + let notificationPreferences: NotificationPreferencesServicing? + + /// Active sessions + revocation (work-consolidation.md G19) for + /// Settings ▸ Security. + let sessions: SessionsServicing? + + /// Tag trending + autocomplete (work-consolidation.md G20). + let tags: TagsServicing? + /// The Direct Messages surface the Messages feature binds against /// (work-consolidation.md G1). Exposed as the protocol so test doubles /// substitute in. Wraps the `/api/messages/*` DM endpoints (folders, @@ -256,7 +275,11 @@ final class AppEnvironment: ObservableObject { directMessages: DirectMessagesServicing, directMessagesEventBus: DirectMessagesEventBus, sharingAPI: APIClientProtocol, - shareBaseURL: URL + shareBaseURL: URL, + appSettings: AppSettingsServicing? = nil, + notificationPreferences: NotificationPreferencesServicing? = nil, + sessions: SessionsServicing? = nil, + tags: TagsServicing? = nil ) { self.messages = messages self.lists = lists @@ -286,8 +309,23 @@ final class AppEnvironment: ObservableObject { self.directMessagesEventBus = directMessagesEventBus self.sharingAPI = sharingAPI self.shareBaseURL = shareBaseURL + self.appSettings = appSettings + self.notificationPreferences = notificationPreferences + self.sessions = sessions + self.tags = tags } + /// The app-settings key this client registers under (work-consolidation.md + /// G17). + /// + /// ⚠️ **Nil until an `appKey` is registered with the backend owner** — that + /// registration is a stated prerequisite of G17, and calling the routes with + /// an unregistered key 404s. While nil, `AppEnvironment.appSettings` is nil + /// and the Settings panes render an explicit "not configured" state instead + /// of failing opaquely. Set this to the agreed key to switch the feature on; + /// it is deliberately the single edit required. + static let appSettingsKey: String? = nil + /// Builds the production service graph: /// /// `KeychainTokenStore` → `DefaultAuthTransport` (Bearer-only for @@ -455,6 +493,16 @@ final class AppEnvironment: ObservableObject { // dock-badge coordinator all see the same stream. let directMessages = DirectMessagesService(api: api) let directMessagesEventBus = DirectMessagesEventBus() + // Settings cluster (work-consolidation.md G17-G20). All reuse the shared + // kit-layer `APIClient`. + // • G17 app settings + device registry — gated on `appSettingsKey` + // being registered with the backend owner; nil until then, and the + // panes say so rather than failing opaquely. + // • G18 notification preferences, G19 sessions, G20 tags. + let appSettings = Self.appSettingsKey.map { AppSettingsService(api: api, appKey: $0) } + let notificationPreferences = NotificationPreferencesService(api: api) + let sessions = SessionsService(api: api) + let tags = TagsService(api: api) return AppEnvironment( messages: messages, lists: lists, @@ -489,7 +537,11 @@ final class AppEnvironment: ObservableObject { // the canonical web-URL builder for links the server returns // without a pre-built `url`. sharingAPI: api, - shareBaseURL: InterlinedKit.defaultBaseURL + shareBaseURL: InterlinedKit.defaultBaseURL, + appSettings: appSettings, + notificationPreferences: notificationPreferences, + sessions: sessions, + tags: tags ) } diff --git a/App/Composition/DeviceIdentity.swift b/App/Composition/DeviceIdentity.swift new file mode 100644 index 0000000..e58dc24 --- /dev/null +++ b/App/Composition/DeviceIdentity.swift @@ -0,0 +1,37 @@ +// DeviceIdentity +// +// This machine's stable identifier for the app-settings device registry +// (work-consolidation.md G17). +// +// Deliberately still `UserDefaults`-backed even though G17 exists to replace +// local settings state: the device id is what *addresses* a row in the remote +// registry, so it cannot itself live there without a chicken-and-egg problem. +// It is an opaque UUID minted once per machine — no hardware identifier, so it +// carries nothing personally identifying and resets cleanly if the user wipes +// preferences. + +import Foundation + +enum DeviceIdentity { + + private static let defaultsKey = "com.interlinedlist.deviceId" + + /// The stable id for this machine, minting and persisting one on first use. + static func current(defaults: UserDefaults = .standard) -> String { + if let existing = defaults.string(forKey: defaultsKey), !existing.isEmpty { + return existing + } + let minted = UUID().uuidString + defaults.set(minted, forKey: defaultsKey) + return minted + } + + /// A human-friendly default name for this machine, used when registering. + /// `ProcessInfo.hostName` avoids AppKit entirely (Decision: SwiftUI-only App + /// target) and matches what the user sees in Sharing preferences. + static var suggestedName: String { + let host = ProcessInfo.processInfo.hostName + // `hostName` often comes back as "studio-mac.local"; trim the suffix. + return host.hasSuffix(".local") ? String(host.dropLast(6)) : host + } +} diff --git a/App/Features/Compose/ComposerWindowView.swift b/App/Features/Compose/ComposerWindowView.swift index 5261f81..d05f794 100644 --- a/App/Features/Compose/ComposerWindowView.swift +++ b/App/Features/Compose/ComposerWindowView.swift @@ -33,6 +33,9 @@ struct ComposerWindowView: View { /// Controls the `.fileImporter` sheet for picking media. @State private var isImporterPresented = false + /// Tag completion (work-consolidation.md G20). Owned by the view, not by + /// `ComposerViewModel`, so the publish path is untouched. + @State private var tagCompletion: TagCompletionViewModel? var body: some View { Group { @@ -435,11 +438,52 @@ struct ComposerWindowView: View { .foregroundStyle(.secondary) TextField("Comma- or space-separated", text: Binding( get: { viewModel.tagsInput }, - set: { viewModel.tagsInput = $0 } + set: { newValue in + viewModel.tagsInput = newValue + // Completion tracks the token currently being typed + // (work-consolidation.md G20). + tagCompletion?.input(changed: newValue) + } )) .textFieldStyle(.roundedBorder) .accessibilityLabel("Tags") + + if let tagCompletion, tagCompletion.isShowing { + tagSuggestions(tagCompletion, viewModel: viewModel) + } + } + .task { + if tagCompletion == nil { + tagCompletion = TagCompletionViewModel(service: environment?.tags) + } + } + } + + /// The completion row. A plain wrapping row rather than a `.popover` so it + /// never steals focus from the field the user is still typing in. + @ViewBuilder + private func tagSuggestions( + _ completion: TagCompletionViewModel, + viewModel: ComposerViewModel + ) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(completion.suggestions, id: \.self) { suggestion in + Button { + viewModel.tagsInput = completion.apply(suggestion, to: viewModel.tagsInput) + } label: { + Text("#\(suggestion)") + .font(.ilMono(10)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.tint.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Add tag \(suggestion)") + } + } } + .frame(maxHeight: 28) } @ViewBuilder diff --git a/App/Features/Compose/TagCompletionViewModel.swift b/App/Features/Compose/TagCompletionViewModel.swift new file mode 100644 index 0000000..9788874 --- /dev/null +++ b/App/Features/Compose/TagCompletionViewModel.swift @@ -0,0 +1,110 @@ +// TagCompletionViewModel +// +// Drives the composer's tag-completion popover (work-consolidation.md G20). +// +// Deliberately owned by the *view*, not by `ComposerViewModel`: completion is a +// presentation affordance over the existing free-form `tagsInput` string, so +// keeping it separate leaves the composer's publish path — and its signature — +// untouched. +// +// The composer's tag field is comma/space separated, so completion applies to +// the **last token being typed**; earlier tokens are already committed. +// +// Per Decision 0003 this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class TagCompletionViewModel { + + private let service: TagsServicing? + /// How long to wait after the last keystroke before asking the server. + private let debounce: Duration + /// The in-flight lookup, cancelled when a newer keystroke supersedes it. + private var task: Task? + + private(set) var suggestions: [String] = [] + + /// True when there is something to show. The view binds its popover to this. + var isShowing: Bool { !suggestions.isEmpty } + + init(service: TagsServicing?, debounce: Duration = .milliseconds(200)) { + self.service = service + self.debounce = debounce + } + + /// Call on every change to the raw tag-input string. + func input(changed raw: String) { + task?.cancel() + let prefix = Self.activeToken(in: raw) + // One character is too weak a prefix to be useful and matches most of + // the corpus; wait for a second before asking. + guard let service, prefix.count >= 2 else { + suggestions = [] + return + } + task = Task { [debounce] in + try? await Task.sleep(for: debounce) + guard !Task.isCancelled else { return } + do { + let found = try await service.suggestions(prefix: prefix, limit: 8) + guard !Task.isCancelled else { return } + // Drop anything already committed earlier in the field. + let committed = Set(Self.committedTokens(in: raw).map { $0.lowercased() }) + suggestions = found.filter { !committed.contains($0.lowercased()) } + } catch { + // Completion is an optional nicety — a failed lookup silently + // shows nothing rather than interrupting composition with an + // error the user cannot act on. + suggestions = [] + } + } + } + + /// Replaces the token being typed with `suggestion`, returning the new field + /// value. Leaves a trailing space so the next tag can be typed immediately. + func apply(_ suggestion: String, to raw: String) -> String { + var tokens = Self.committedTokens(in: raw) + tokens.append(suggestion) + suggestions = [] + task?.cancel() + return tokens.joined(separator: " ") + " " + } + + func dismiss() { + task?.cancel() + suggestions = [] + } + + // MARK: - Token parsing + // + // Mirrors `ComposerViewModel.normalise(tags:)`: split on commas and + // whitespace, strip a leading `#`. + + private static let separators = CharacterSet(charactersIn: ", \t\n") + + /// The token currently being typed — the trailing fragment. Empty when the + /// field ends in a separator (nothing is being typed right now). + static func activeToken(in raw: String) -> String { + guard let last = raw.unicodeScalars.last, !separators.contains(last) else { return "" } + let fragment = raw.components(separatedBy: separators as CharacterSet).last ?? "" + return fragment.hasPrefix("#") ? String(fragment.dropFirst()) : fragment + } + + /// Every token except the one being typed. + static func committedTokens(in raw: String) -> [String] { + var parts = raw + .components(separatedBy: separators as CharacterSet) + .map { $0.hasPrefix("#") ? String($0.dropFirst()) : $0 } + .filter { !$0.isEmpty } + // If the field does not end in a separator, the final part is still + // being typed and is not yet committed. + if let last = raw.unicodeScalars.last, !separators.contains(last), !parts.isEmpty { + parts.removeLast() + } + return parts + } +} diff --git a/App/Features/Settings/DevicesView.swift b/App/Features/Settings/DevicesView.swift new file mode 100644 index 0000000..a8dfdac --- /dev/null +++ b/App/Features/Settings/DevicesView.swift @@ -0,0 +1,146 @@ +// DevicesView +// +// Settings ▸ Devices (work-consolidation.md G17) — the machines registered under +// this app's key. Rename a machine, promote one to main workstation (its config +// seeds a brand-new device on first sign-in), or deregister one. +// +// SwiftUI-only (no AppKit). Consumes only `InterlinedDomain` per Decision 0003. + +import SwiftUI +import InterlinedDomain + +struct DevicesView: View { + + @Environment(\.appEnvironment) private var environment + @State private var viewModel: DevicesViewModel? + @State private var renaming: AppDevice? + @State private var draftName: String = "" + @State private var pendingDeregister: AppDevice? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + if viewModel == nil { + let model = DevicesViewModel( + service: environment?.appSettings, + currentDeviceID: DeviceIdentity.current() + ) + viewModel = model + await model.load() + } + } + } + + @ViewBuilder + private func content(_ viewModel: DevicesViewModel) -> some View { + if viewModel.isUnavailable { + SettingsUnavailableView( + title: "Devices unavailable", + message: "Synced settings need an app key registered with InterlinedList before this Mac can appear here." + ) + } else { + Form { + if let error = viewModel.error { + Section { SettingsErrorRow(error: error) } + } + Section("Registered devices") { + if viewModel.isLoading && viewModel.devices.isEmpty { + ProgressView() + } else if viewModel.devices.isEmpty { + Text("No devices registered yet.").foregroundStyle(.secondary) + } else { + ForEach(viewModel.devices) { device in + row(device, viewModel: viewModel) + } + } + } + } + .formStyle(.grouped) + .alert("Rename device", isPresented: Binding( + get: { renaming != nil }, + set: { if !$0 { renaming = nil } } + )) { + TextField("Name", text: $draftName) + Button("Rename") { + if let device = renaming { + Task { await viewModel.rename(device, to: draftName); renaming = nil } + } + } + Button("Cancel", role: .cancel) { renaming = nil } + } + .confirmationDialog( + "Deregister this device?", + isPresented: Binding( + get: { pendingDeregister != nil }, + set: { if !$0 { pendingDeregister = nil } } + ), + presenting: pendingDeregister + ) { device in + Button("Deregister", role: .destructive) { + Task { await viewModel.deregister(device); pendingDeregister = nil } + } + Button("Cancel", role: .cancel) { pendingDeregister = nil } + } message: { device in + Text("\(device.name) will lose its per-machine settings. Shared settings are unaffected.") + } + } + } + + @ViewBuilder + private func row(_ device: AppDevice, viewModel: DevicesViewModel) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(device.name) + if viewModel.isCurrentDevice(device) { + Text("This Mac") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(.tint.opacity(0.15), in: Capsule()) + } + if device.isMainWorkstation { + Label("Main", systemImage: "star.fill") + .labelStyle(.titleAndIcon) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + if let lastSeen = device.lastSeenAt { + Text("Last seen \(lastSeen.formatted(.relative(presentation: .named)))") + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if viewModel.busyID == device.id { + ProgressView().controlSize(.small) + } else { + Menu { + Button("Rename…") { renaming = device; draftName = device.name } + if !device.isMainWorkstation { + Button("Make main workstation") { + Task { await viewModel.makeMainWorkstation(device) } + } + } + Divider() + Button("Deregister…", role: .destructive) { pendingDeregister = device } + } label: { + Label("Actions", systemImage: "ellipsis.circle") + .labelStyle(.iconOnly) + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(viewModel.busyID != nil) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel( + device.isMainWorkstation ? "\(device.name), main workstation" : device.name + ) + } +} diff --git a/App/Features/Settings/DevicesViewModel.swift b/App/Features/Settings/DevicesViewModel.swift new file mode 100644 index 0000000..c3d4055 --- /dev/null +++ b/App/Features/Settings/DevicesViewModel.swift @@ -0,0 +1,115 @@ +// DevicesViewModel +// +// Drives Settings ▸ Devices (work-consolidation.md G17) — the machines +// registered under this app's key, with rename, "make main workstation", and +// deregister actions. +// +// The main workstation matters because its configuration seeds a brand-new +// device on first sign-in, so promoting one is a real, user-visible decision +// rather than cosmetic. +// +// Per Decision 0003 this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class DevicesViewModel { + + private let service: AppSettingsServicing? + /// This machine's stable id, so the list can mark "This Mac". + private let currentDeviceID: String + + private(set) var devices: [AppDevice] = [] + private(set) var isLoading = false + /// The device id with an action in flight, so only that row shows progress. + private(set) var busyID: String? + private(set) var error: Error? + + /// True when no `appKey` is registered yet (see `AppEnvironment.appSettingsKey`). + var isUnavailable: Bool { service == nil } + + init(service: AppSettingsServicing?, currentDeviceID: String) { + self.service = service + self.currentDeviceID = currentDeviceID + } + + func isCurrentDevice(_ device: AppDevice) -> Bool { device.id == currentDeviceID } + + func load() async { + guard let service else { return } + isLoading = true + error = nil + defer { isLoading = false } + do { + // This Mac first, then the main workstation, then by name — the two + // rows a user acts on are the ones they can identify. + devices = try await service.devices().sorted { lhs, rhs in + if isCurrentDevice(lhs) != isCurrentDevice(rhs) { return isCurrentDevice(lhs) } + if lhs.isMainWorkstation != rhs.isMainWorkstation { return lhs.isMainWorkstation } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } catch { + self.error = error + } + } + + func rename(_ device: AppDevice, to name: String) async { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard let service, busyID == nil, !trimmed.isEmpty, trimmed != device.name else { return } + busyID = device.id + error = nil + defer { busyID = nil } + do { + let updated = try await service.renameDevice(deviceID: device.id, to: trimmed) + replace(updated) + } catch { + self.error = error + } + } + + func makeMainWorkstation(_ device: AppDevice) async { + guard let service, busyID == nil, !device.isMainWorkstation else { return } + busyID = device.id + error = nil + defer { busyID = nil } + do { + let updated = try await service.makeMainWorkstation(deviceID: device.id) + // Exactly one device holds the flag, so clear it locally everywhere + // else rather than re-fetching the whole registry. + devices = devices.map { existing in + guard existing.id != updated.id, existing.isMainWorkstation else { return existing } + return AppDevice( + id: existing.id, + name: existing.name, + isMainWorkstation: false, + createdAt: existing.createdAt, + lastSeenAt: existing.lastSeenAt + ) + } + replace(updated) + } catch { + self.error = error + } + } + + func deregister(_ device: AppDevice) async { + guard let service, busyID == nil else { return } + busyID = device.id + error = nil + defer { busyID = nil } + do { + try await service.deregisterDevice(deviceID: device.id) + devices.removeAll { $0.id == device.id } + } catch { + self.error = error + } + } + + private func replace(_ device: AppDevice) { + guard let index = devices.firstIndex(where: { $0.id == device.id }) else { return } + devices[index] = device + } +} diff --git a/App/Features/Settings/NotificationPreferencesView.swift b/App/Features/Settings/NotificationPreferencesView.swift new file mode 100644 index 0000000..35bb4d6 --- /dev/null +++ b/App/Features/Settings/NotificationPreferencesView.swift @@ -0,0 +1,94 @@ +// NotificationPreferencesView +// +// Settings ▸ Notifications (work-consolidation.md G18). The pane is entirely +// data-driven: every row, its label, its description and which channel switches +// appear all come from the server's catalogue, so a new event type shows up +// without a client release. +// +// SwiftUI-only (no AppKit). Consumes only `InterlinedDomain` per Decision 0003. + +import SwiftUI +import InterlinedDomain + +struct NotificationPreferencesView: View { + + @Environment(\.appEnvironment) private var environment + @State private var viewModel: NotificationPreferencesViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + if viewModel == nil { + let model = NotificationPreferencesViewModel( + service: environment?.notificationPreferences + ) + viewModel = model + await model.load() + } + } + } + + @ViewBuilder + private func content(_ viewModel: NotificationPreferencesViewModel) -> some View { + if viewModel.isUnavailable { + SettingsUnavailableView( + title: "Notification preferences unavailable", + message: "This build has no notification-preferences service configured." + ) + } else { + Form { + if let error = viewModel.error { + Section { SettingsErrorRow(error: error) } + } + if viewModel.isLoading && viewModel.events.isEmpty { + Section { ProgressView() } + } else if viewModel.events.isEmpty { + Section { + Text("No notification events available.").foregroundStyle(.secondary) + } + } else { + ForEach(viewModel.events) { event in + Section { + // Only render channels the server actually offered + // for this event — a dead switch is worse than none. + ForEach(NotificationChannel.allCases) { channel in + if let value = viewModel.channelValue(event.key, channel) { + Toggle( + channel.title, + isOn: Binding( + get: { value }, + set: { viewModel.setChannel(event.key, channel, to: $0) } + ) + ) + } + } + } header: { + Text(event.label) + } footer: { + if let description = event.description { + Text(description).font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + .formStyle(.grouped) + .safeAreaInset(edge: .bottom) { + HStack { + Spacer() + if viewModel.isSaving { ProgressView().controlSize(.small) } + Button("Save") { Task { await viewModel.save() } } + .disabled(!viewModel.hasChanges || viewModel.isSaving) + .keyboardShortcut("s") + } + .padding(.horizontal).padding(.bottom, 8) + } + } + } +} diff --git a/App/Features/Settings/NotificationPreferencesViewModel.swift b/App/Features/Settings/NotificationPreferencesViewModel.swift new file mode 100644 index 0000000..2e4beba --- /dev/null +++ b/App/Features/Settings/NotificationPreferencesViewModel.swift @@ -0,0 +1,108 @@ +// NotificationPreferencesViewModel +// +// Drives Settings ▸ Notifications (work-consolidation.md G18). The catalogue is +// server-driven — labels, descriptions and which channels exist all come from +// the payload — so this view model holds an opaque list of events and never +// hard-codes an event type. +// +// Per Decision 0003 this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class NotificationPreferencesViewModel { + + private let service: NotificationPreferencesServicing? + + /// The working copy bound to the pane's switches. + var events: [NotificationEventPreference] = [] + /// The last catalogue the server confirmed, for change detection. + private(set) var lastSaved: [NotificationEventPreference] = [] + + private(set) var isLoading = false + private(set) var isSaving = false + private(set) var error: Error? + + var isUnavailable: Bool { service == nil } + var hasChanges: Bool { events != lastSaved } + + init(service: NotificationPreferencesServicing?) { + self.service = service + } + + func load() async { + guard let service else { return } + isLoading = true + error = nil + defer { isLoading = false } + do { + let loaded = try await service.catalogue() + events = loaded + lastSaved = loaded + } catch { + self.error = error + } + } + + func save() async { + guard let service, hasChanges, !isSaving else { return } + isSaving = true + error = nil + defer { isSaving = false } + do { + // Send only what changed — an untouched event has no reason to be + // rewritten, and a narrower PATCH is less likely to clobber a change + // made on another device between this load and save. + let changed = events.filter { event in + lastSaved.first { $0.key == event.key }?.channels != event.channels + } + let updated = try await service.update(changed) + events = updated + lastSaved = updated + } catch { + self.error = error + } + } + + /// Binding helper for one event's channel toggle. Returns nil when the + /// server did not offer that channel for the event, so the view can omit + /// the switch rather than render a dead one. + func channelValue(_ key: String, _ channel: NotificationChannel) -> Bool? { + guard let event = events.first(where: { $0.key == key }) else { return nil } + switch channel { + case .push: return event.channels.push + case .inApp: return event.channels.inApp + case .email: return event.channels.email + } + } + + func setChannel(_ key: String, _ channel: NotificationChannel, to value: Bool) { + guard let index = events.firstIndex(where: { $0.key == key }) else { return } + switch channel { + case .push: events[index].channels.push = value + case .inApp: events[index].channels.inApp = value + case .email: events[index].channels.email = value + } + } +} + +/// The delivery channels the pane can render. App-layer only — the domain model +/// carries the values, this just names them for the view's iteration. +enum NotificationChannel: String, CaseIterable, Identifiable { + case push + case inApp + case email + + var id: String { rawValue } + + var title: String { + switch self { + case .push: return "Push" + case .inApp: return "In app" + case .email: return "Email" + } + } +} diff --git a/App/Features/Settings/SecuritySessionsView.swift b/App/Features/Settings/SecuritySessionsView.swift new file mode 100644 index 0000000..6f33c9d --- /dev/null +++ b/App/Features/Settings/SecuritySessionsView.swift @@ -0,0 +1,119 @@ +// SecuritySessionsView +// +// Settings ▸ Security (work-consolidation.md G19) — the account's active +// sessions with a per-row Revoke action. The honest complement to a +// never-expiring sync token: if a machine is lost, this is where the user cuts +// it off. +// +// SwiftUI-only (no AppKit). Consumes only `InterlinedDomain` per Decision 0003. + +import SwiftUI +import InterlinedDomain + +struct SecuritySessionsView: View { + + @Environment(\.appEnvironment) private var environment + @State private var viewModel: SessionsViewModel? + /// The session awaiting confirmation, when revoking would sign us out. + @State private var pendingCurrentRevoke: ActiveSession? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + if viewModel == nil { + let model = SessionsViewModel(service: environment?.sessions) + viewModel = model + await model.load() + } + } + } + + @ViewBuilder + private func content(_ viewModel: SessionsViewModel) -> some View { + if viewModel.isUnavailable { + SettingsUnavailableView( + title: "Sessions unavailable", + message: "This build has no sessions service configured." + ) + } else { + Form { + if let error = viewModel.error { + Section { SettingsErrorRow(error: error) } + } + Section("Active sessions") { + if viewModel.isLoading && viewModel.sessions.isEmpty { + ProgressView() + } else if viewModel.sessions.isEmpty { + Text("No active sessions.").foregroundStyle(.secondary) + } else { + ForEach(viewModel.sessions) { session in + row(session, viewModel: viewModel) + } + } + } + } + .formStyle(.grouped) + .confirmationDialog( + "Revoke this session?", + isPresented: Binding( + get: { pendingCurrentRevoke != nil }, + set: { if !$0 { pendingCurrentRevoke = nil } } + ), + presenting: pendingCurrentRevoke + ) { session in + Button("Revoke and sign out", role: .destructive) { + Task { await viewModel.revoke(session); pendingCurrentRevoke = nil } + } + Button("Cancel", role: .cancel) { pendingCurrentRevoke = nil } + } message: { _ in + Text("This is the session this app is using. Revoking it signs you out on this Mac.") + } + } + } + + @ViewBuilder + private func row(_ session: ActiveSession, viewModel: SessionsViewModel) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(session.deviceLabel) + if session.isCurrent { + Text("This Mac") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(.tint.opacity(0.15), in: Capsule()) + } + } + if let lastUsed = session.lastUsedAt { + Text("Last used \(lastUsed.formatted(.relative(presentation: .named)))") + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if viewModel.revokingID == session.id { + ProgressView().controlSize(.small) + } else { + Button("Revoke") { + if session.isCurrent { + pendingCurrentRevoke = session + } else { + Task { await viewModel.revoke(session) } + } + } + .disabled(viewModel.revokingID != nil) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel( + session.isCurrent + ? "\(session.deviceLabel), this Mac's session" + : session.deviceLabel + ) + } +} diff --git a/App/Features/Settings/SessionsViewModel.swift b/App/Features/Settings/SessionsViewModel.swift new file mode 100644 index 0000000..20def1e --- /dev/null +++ b/App/Features/Settings/SessionsViewModel.swift @@ -0,0 +1,66 @@ +// SessionsViewModel +// +// Drives Settings ▸ Security — the account's active sessions with a per-row +// Revoke action (work-consolidation.md G19). +// +// Reads through `SessionsServicing` only, so a stub drives the tests without +// networking. Per Decision 0003 this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class SessionsViewModel { + + private let service: SessionsServicing? + + private(set) var sessions: [ActiveSession] = [] + private(set) var isLoading = false + /// The session id currently being revoked, so only that row shows a spinner. + private(set) var revokingID: String? + private(set) var error: Error? + + /// True when the feature has no service wired (see `AppEnvironment.sessions`). + var isUnavailable: Bool { service == nil } + + init(service: SessionsServicing?) { + self.service = service + } + + func load() async { + guard let service else { return } + isLoading = true + error = nil + defer { isLoading = false } + do { + // Current session first, then most-recently-used — the row a user + // looks for is either "this Mac" or the one that just appeared. + sessions = try await service.sessions().sorted { lhs, rhs in + if lhs.isCurrent != rhs.isCurrent { return lhs.isCurrent } + return (lhs.lastUsedAt ?? .distantPast) > (rhs.lastUsedAt ?? .distantPast) + } + } catch { + self.error = error + } + } + + /// Revokes one session and drops it from the list on success. + /// + /// Revoking the *current* session signs this app out, so the view guards + /// that behind a confirmation; this method does not re-check, it just + /// performs what it was asked. + func revoke(_ session: ActiveSession) async { + guard let service, revokingID == nil else { return } + revokingID = session.id + error = nil + defer { revokingID = nil } + do { + try await service.revoke(sessionID: session.id) + sessions.removeAll { $0.id == session.id } + } catch { + self.error = error + } + } +} diff --git a/App/Features/Settings/SettingsRootView.swift b/App/Features/Settings/SettingsRootView.swift index e961104..6e55a77 100644 --- a/App/Features/Settings/SettingsRootView.swift +++ b/App/Features/Settings/SettingsRootView.swift @@ -40,6 +40,25 @@ struct SettingsRootView: View { Label("Blocked & Muted", systemImage: "hand.raised") } + // Server-driven notification event catalogue (work-consolidation.md G18). + NotificationPreferencesView() + .tabItem { + Label("Notifications", systemImage: "bell") + } + + // Active sessions with per-row revoke (work-consolidation.md G19). + SecuritySessionsView() + .tabItem { + Label("Security", systemImage: "lock.shield") + } + + // Synced-settings device registry (work-consolidation.md G17) — the + // machines registered under this app's key. + DevicesView() + .tabItem { + Label("Devices", systemImage: "desktopcomputer") + } + // Document sync agent (work-consolidation.md §3b) — enable the background helper // that mirrors documents to a local folder for Obsidian. DocumentSyncSettingsView() @@ -47,7 +66,7 @@ struct SettingsRootView: View { Label("Document Sync", systemImage: "arrow.triangle.2.circlepath") } } - .frame(width: 560, height: 500) + .frame(width: 620, height: 520) } } diff --git a/App/Features/Settings/SettingsSharedRows.swift b/App/Features/Settings/SettingsSharedRows.swift new file mode 100644 index 0000000..06812b6 --- /dev/null +++ b/App/Features/Settings/SettingsSharedRows.swift @@ -0,0 +1,36 @@ +// SettingsSharedRows +// +// Two small presentational helpers shared by the Settings-cluster panes +// (work-consolidation.md G17-G20) so each pane renders "unavailable" and +// "something failed" the same way. +// +// SwiftUI-only; no domain or kit types beyond `Error`. + +import SwiftUI + +/// Shown when a pane's backing service is not configured in this build. +struct SettingsUnavailableView: View { + let title: String + let message: String + + var body: some View { + ContentUnavailableView { + Label(title, systemImage: "gearshape.badge.xmark") + } description: { + Text(message) + } + } +} + +/// A compact inline error row. Uses the friendly message when the error carries +/// one, falling back to the localized description. +struct SettingsErrorRow: View { + let error: Error + + var body: some View { + Label(error.localizedDescription, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + .font(.callout) + .accessibilityLabel("Error: \(error.localizedDescription)") + } +} diff --git a/App/Features/Timeline/TimelineRootView.swift b/App/Features/Timeline/TimelineRootView.swift index 80743d5..591f2df 100644 --- a/App/Features/Timeline/TimelineRootView.swift +++ b/App/Features/Timeline/TimelineRootView.swift @@ -195,6 +195,11 @@ struct TimelineRootView: View { private func timelineBody(viewModel: TimelineViewModel) -> some View { VStack(spacing: 0) { toolbar(viewModel: viewModel) + // Trending tags (work-consolidation.md G20). Hides itself when the + // list is empty or unavailable, so it costs no space by default. + TrendingTagsStrip(activeTag: viewModel.tagFilter) { tag in + await viewModel.setTagFilter(tag) + } Divider() content(viewModel: viewModel) } diff --git a/App/Features/Timeline/TrendingTagsStrip.swift b/App/Features/Timeline/TrendingTagsStrip.swift new file mode 100644 index 0000000..d0dc0e7 --- /dev/null +++ b/App/Features/Timeline/TrendingTagsStrip.swift @@ -0,0 +1,75 @@ +// TrendingTagsStrip +// +// The timeline's horizontal trending-tags strip (work-consolidation.md G20). +// Tapping a tag applies the timeline's existing tag filter; tapping the active +// one clears it. +// +// SwiftUI-only (no AppKit). Consumes only `InterlinedDomain` per Decision 0003. + +import SwiftUI +import InterlinedDomain + +struct TrendingTagsStrip: View { + + @Environment(\.appEnvironment) private var environment + /// The currently applied filter, so the active chip reads as selected. + let activeTag: String? + /// Applies (or clears) the filter. Owned by the timeline. + let onSelect: (String?) async -> Void + + @State private var viewModel: TrendingTagsViewModel? + + var body: some View { + Group { + if let viewModel, viewModel.isVisible { + strip(viewModel) + } + } + .task { + if viewModel == nil { + let model = TrendingTagsViewModel(service: environment?.tags) + viewModel = model + await model.load() + } + } + } + + @ViewBuilder + private func strip(_ viewModel: TrendingTagsViewModel) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(viewModel.tags) { tag in + let isActive = tag.name.caseInsensitiveCompare(activeTag ?? "") == .orderedSame + Button { + // Tapping the active chip clears the filter. + Task { await onSelect(isActive ? nil : tag.name) } + } label: { + HStack(spacing: 4) { + Text("#\(tag.name)") + if tag.count > 0 { + Text("\(tag.count)") + .foregroundStyle(.secondary) + } + } + .font(.ilMono(10)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.tint.opacity(0.12)), + in: Capsule() + ) + .foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + } + .buttonStyle(.plain) + .accessibilityLabel( + isActive + ? "Clear filter for tag \(tag.name)" + : "Filter timeline by tag \(tag.name), \(tag.count) posts" + ) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + } +} diff --git a/App/Features/Timeline/TrendingTagsViewModel.swift b/App/Features/Timeline/TrendingTagsViewModel.swift new file mode 100644 index 0000000..e9f1741 --- /dev/null +++ b/App/Features/Timeline/TrendingTagsViewModel.swift @@ -0,0 +1,48 @@ +// TrendingTagsViewModel +// +// Drives the timeline's trending-tags strip (work-consolidation.md G20). +// +// Kept separate from `TimelineViewModel` so the timeline's load path and its +// signature are untouched: this only feeds a presentational strip whose taps +// call the timeline's existing `setTagFilter`. +// +// Per Decision 0003 this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class TrendingTagsViewModel { + + private let service: TagsServicing? + private let limit: Int + + private(set) var tags: [TrendingTag] = [] + private(set) var isLoading = false + + /// Whether the strip has anything to render. The timeline hides the strip + /// entirely when false, so an unavailable or empty trending list costs no + /// vertical space. + var isVisible: Bool { !tags.isEmpty } + + init(service: TagsServicing?, limit: Int = 12) { + self.service = service + self.limit = limit + } + + func load() async { + guard let service, !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + tags = try await service.trending(limit: limit) + } catch { + // A trending strip is ambient decoration over the real feed — a + // failure hides it rather than pushing an error the user did not ask + // for in front of their timeline. + tags = [] + } + } +} diff --git a/AppTests/DevicesViewModelTests.swift b/AppTests/DevicesViewModelTests.swift new file mode 100644 index 0000000..bcef331 --- /dev/null +++ b/AppTests/DevicesViewModelTests.swift @@ -0,0 +1,156 @@ +// DevicesViewModelTests +// +// BDD-named tests for Settings ▸ Devices (work-consolidation.md G17). + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class DevicesViewModelTests: XCTestCase { + + private let thisMac = "dev-this" + + private func device(_ id: String, name: String? = nil, main: Bool = false) -> AppDevice { + AppDevice(id: id, name: name ?? id, isMainWorkstation: main) + } + + private func makeViewModel(_ stub: StubAppSettingsService?) -> DevicesViewModel { + DevicesViewModel(service: stub, currentDeviceID: thisMac) + } + + // MARK: - Happy path + + func test_givenDevices_whenLoading_thenOrdersThisMacThenMainThenByName() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(success: [ + device("z", name: "Zulu"), + device("m", name: "Main", main: true), + device(thisMac, name: "This Mac"), + device("a", name: "Alpha") + ]) + let viewModel = makeViewModel(stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.devices.map(\.id), [thisMac, "m", "a", "z"]) + XCTAssertTrue(viewModel.isCurrentDevice(viewModel.devices[0])) + } + + func test_givenRename_whenRenaming_thenSendsTrimmedNameAndUpdatesRow() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(success: [device("a", name: "Old")]) + stub.enqueueMutation(success: device("a", name: "New")) + let viewModel = makeViewModel(stub) + await viewModel.load() + + await viewModel.rename(viewModel.devices[0], to: " New ") + + XCTAssertEqual(stub.renamedTo["a"], "New") + XCTAssertEqual(viewModel.devices[0].name, "New") + } + + func test_givenPromotion_whenMakingMain_thenClearsTheFlagOnTheOldMain() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(success: [device("old", main: true), device("new")]) + stub.enqueueMutation(success: device("new", main: true)) + let viewModel = makeViewModel(stub) + await viewModel.load() + let target = viewModel.devices.first { $0.id == "new" }! + + await viewModel.makeMainWorkstation(target) + + // Exactly one main workstation may exist, so the old one must flip + // locally without a second round-trip. + XCTAssertEqual(stub.promotedIDs, ["new"]) + XCTAssertTrue(viewModel.devices.first { $0.id == "new" }!.isMainWorkstation) + XCTAssertFalse(viewModel.devices.first { $0.id == "old" }!.isMainWorkstation) + } + + func test_givenDevice_whenDeregistering_thenRemovesTheRow() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(success: [device("a"), device("b")]) + stub.enqueueDeregister() + let viewModel = makeViewModel(stub) + await viewModel.load() + + await viewModel.deregister(device("a")) + + XCTAssertEqual(viewModel.devices.map(\.id), ["b"]) + XCTAssertEqual(stub.deregisteredIDs, ["a"]) + } + + // MARK: - Invalid input + + func test_givenBlankOrUnchangedName_whenRenaming_thenSkipsTheRequest() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(success: [device("a", name: "Same")]) + let viewModel = makeViewModel(stub) + await viewModel.load() + + await viewModel.rename(viewModel.devices[0], to: " ") + await viewModel.rename(viewModel.devices[0], to: "Same") + + XCTAssertTrue(stub.renamedTo.isEmpty, "a blank or unchanged rename must not round-trip") + } + + func test_givenAlreadyMain_whenMakingMain_thenSkipsTheRequest() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(success: [device("a", main: true)]) + let viewModel = makeViewModel(stub) + await viewModel.load() + + await viewModel.makeMainWorkstation(viewModel.devices[0]) + + XCTAssertTrue(stub.promotedIDs.isEmpty) + } + + func test_givenNoAppKeyRegistered_whenLoading_thenReportsUnavailable() async { + let viewModel = makeViewModel(nil) + + await viewModel.load() + + XCTAssertTrue(viewModel.isUnavailable) + XCTAssertTrue(viewModel.devices.isEmpty) + XCTAssertNil(viewModel.error, "an unconfigured appKey is a state, not an error") + } + + // MARK: - Upstream failure + + func test_givenLoadFailure_whenLoading_thenSurfacesError() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(failure: URLError(.notConnectedToInternet)) + let viewModel = makeViewModel(stub) + + await viewModel.load() + + XCTAssertNotNil(viewModel.error) + XCTAssertTrue(viewModel.devices.isEmpty) + } + + func test_givenDeregisterFailure_whenDeregistering_thenKeepsRowAndSurfacesError() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(success: [device("a")]) + stub.enqueueDeregister(failure: URLError(.badServerResponse)) + let viewModel = makeViewModel(stub) + await viewModel.load() + + await viewModel.deregister(device("a")) + + XCTAssertEqual(viewModel.devices.map(\.id), ["a"]) + XCTAssertNotNil(viewModel.error) + } + + // MARK: - Empty / boundary + + func test_givenNoDevices_whenLoading_thenEmptyWithoutError() async { + let stub = StubAppSettingsService() + stub.enqueueDevices(success: []) + let viewModel = makeViewModel(stub) + + await viewModel.load() + + XCTAssertTrue(viewModel.devices.isEmpty) + XCTAssertNil(viewModel.error) + } +} diff --git a/AppTests/NotificationPreferencesViewModelTests.swift b/AppTests/NotificationPreferencesViewModelTests.swift new file mode 100644 index 0000000..b808520 --- /dev/null +++ b/AppTests/NotificationPreferencesViewModelTests.swift @@ -0,0 +1,120 @@ +// NotificationPreferencesViewModelTests +// +// BDD-named tests for Settings ▸ Notifications (work-consolidation.md G18). + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class NotificationPreferencesViewModelTests: XCTestCase { + + private func event( + _ key: String, + label: String? = nil, + push: Bool? = true, + inApp: Bool? = true, + email: Bool? = nil + ) -> NotificationEventPreference { + NotificationEventPreference( + key: key, + label: label ?? key, + channels: NotificationChannels(push: push, inApp: inApp, email: email) + ) + } + + // MARK: - Happy path + + func test_givenCatalogue_whenLoading_thenPopulatesWithNoUnsavedChanges() async { + let stub = StubNotificationPreferencesService() + stub.enqueueCatalogue(success: [event("dig", label: "Digs")]) + let viewModel = NotificationPreferencesViewModel(service: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.events.map(\.key), ["dig"]) + XCTAssertFalse(viewModel.hasChanges) + XCTAssertNil(viewModel.error) + } + + func test_givenChangedChannel_whenSaving_thenSendsOnlyTheChangedEvent() async { + let stub = StubNotificationPreferencesService() + stub.enqueueCatalogue(success: [event("dig"), event("follow")]) + stub.enqueueUpdate(success: [event("dig", push: false), event("follow")]) + let viewModel = NotificationPreferencesViewModel(service: stub) + await viewModel.load() + + viewModel.setChannel("dig", .push, to: false) + XCTAssertTrue(viewModel.hasChanges) + await viewModel.save() + + // Only the edited event travels — a narrower PATCH is less likely to + // clobber a change made on another device. + XCTAssertEqual(stub.lastUpdatePayload.map(\.key), ["dig"]) + XCTAssertFalse(viewModel.hasChanges) + } + + // MARK: - Invalid / unavailable + + func test_givenChannelTheServerOmitted_whenReading_thenReturnsNilSoTheSwitchIsHidden() async { + let stub = StubNotificationPreferencesService() + stub.enqueueCatalogue(success: [event("dig", email: nil)]) + let viewModel = NotificationPreferencesViewModel(service: stub) + await viewModel.load() + + XCTAssertNil(viewModel.channelValue("dig", .email)) + XCTAssertEqual(viewModel.channelValue("dig", .push), true) + XCTAssertNil(viewModel.channelValue("missing-event", .push)) + } + + func test_givenNoService_whenLoading_thenReportsUnavailable() async { + let viewModel = NotificationPreferencesViewModel(service: nil) + + await viewModel.load() + + XCTAssertTrue(viewModel.isUnavailable) + XCTAssertTrue(viewModel.events.isEmpty) + } + + func test_givenNoChanges_whenSaving_thenDoesNotCallTheServer() async { + let stub = StubNotificationPreferencesService() + stub.enqueueCatalogue(success: [event("dig")]) + let viewModel = NotificationPreferencesViewModel(service: stub) + await viewModel.load() + + await viewModel.save() + + XCTAssertTrue(stub.lastUpdatePayload.isEmpty, "an unchanged pane must not round-trip") + } + + // MARK: - Upstream failure + + func test_givenSaveFailure_whenSaving_thenSurfacesErrorAndKeepsEdits() async { + let stub = StubNotificationPreferencesService() + stub.enqueueCatalogue(success: [event("dig")]) + stub.enqueueUpdate(failure: URLError(.timedOut)) + let viewModel = NotificationPreferencesViewModel(service: stub) + await viewModel.load() + viewModel.setChannel("dig", .push, to: false) + + await viewModel.save() + + XCTAssertNotNil(viewModel.error) + // The user's edit must survive a failed save so they can retry. + XCTAssertEqual(viewModel.channelValue("dig", .push), false) + XCTAssertTrue(viewModel.hasChanges) + } + + // MARK: - Empty / boundary + + func test_givenEmptyCatalogue_whenLoading_thenNoEventsAndNoError() async { + let stub = StubNotificationPreferencesService() + stub.enqueueCatalogue(success: []) + let viewModel = NotificationPreferencesViewModel(service: stub) + + await viewModel.load() + + XCTAssertTrue(viewModel.events.isEmpty) + XCTAssertNil(viewModel.error) + } +} diff --git a/AppTests/SessionsViewModelTests.swift b/AppTests/SessionsViewModelTests.swift new file mode 100644 index 0000000..f79672d --- /dev/null +++ b/AppTests/SessionsViewModelTests.swift @@ -0,0 +1,106 @@ +// SessionsViewModelTests +// +// BDD-named tests for Settings ▸ Security (work-consolidation.md G19). + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class SessionsViewModelTests: XCTestCase { + + private func session( + _ id: String, + label: String = "Mac", + isCurrent: Bool = false, + lastUsed: Date? = nil + ) -> ActiveSession { + ActiveSession(id: id, deviceLabel: label, lastUsedAt: lastUsed, isCurrent: isCurrent) + } + + // MARK: - Happy path + + func test_givenSessions_whenLoading_thenOrdersCurrentFirstThenMostRecent() async { + let stub = StubSessionsService() + let old = Date(timeIntervalSince1970: 1_000) + let recent = Date(timeIntervalSince1970: 9_000) + stub.enqueueSessions(success: [ + session("a", label: "Old", lastUsed: old), + session("b", label: "Recent", lastUsed: recent), + session("c", label: "This Mac", isCurrent: true, lastUsed: old) + ]) + let viewModel = SessionsViewModel(service: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.sessions.map(\.id), ["c", "b", "a"]) + XCTAssertNil(viewModel.error) + } + + func test_givenSession_whenRevoking_thenRemovesItAndCallsService() async { + let stub = StubSessionsService() + stub.enqueueSessions(success: [session("a"), session("b")]) + stub.enqueueRevoke() + let viewModel = SessionsViewModel(service: stub) + await viewModel.load() + + await viewModel.revoke(session("a")) + + XCTAssertEqual(viewModel.sessions.map(\.id), ["b"]) + XCTAssertEqual(stub.revokedIDs, ["a"]) + XCTAssertNil(viewModel.revokingID) + } + + // MARK: - Invalid / unavailable + + func test_givenNoService_whenLoading_thenReportsUnavailableAndDoesNothing() async { + let viewModel = SessionsViewModel(service: nil) + + await viewModel.load() + + XCTAssertTrue(viewModel.isUnavailable) + XCTAssertTrue(viewModel.sessions.isEmpty) + XCTAssertNil(viewModel.error) + } + + // MARK: - Upstream failure + + func test_givenLoadFailure_whenLoading_thenSurfacesErrorRatherThanEmptyList() async { + let stub = StubSessionsService() + stub.enqueueSessions(failure: URLError(.notConnectedToInternet)) + let viewModel = SessionsViewModel(service: stub) + + await viewModel.load() + + XCTAssertNotNil(viewModel.error) + XCTAssertTrue(viewModel.sessions.isEmpty) + } + + func test_givenRevokeFailure_whenRevoking_thenKeepsRowAndSurfacesError() async { + let stub = StubSessionsService() + stub.enqueueSessions(success: [session("a")]) + stub.enqueueRevoke(failure: URLError(.badServerResponse)) + let viewModel = SessionsViewModel(service: stub) + await viewModel.load() + + await viewModel.revoke(session("a")) + + // The row must survive a failed revoke — showing it gone would imply a + // revocation that never happened. + XCTAssertEqual(viewModel.sessions.map(\.id), ["a"]) + XCTAssertNotNil(viewModel.error) + } + + // MARK: - Empty / boundary + + func test_givenNoSessions_whenLoading_thenListIsEmptyWithoutError() async { + let stub = StubSessionsService() + stub.enqueueSessions(success: []) + let viewModel = SessionsViewModel(service: stub) + + await viewModel.load() + + XCTAssertTrue(viewModel.sessions.isEmpty) + XCTAssertNil(viewModel.error) + } +} diff --git a/AppTests/Support/StubSettingsClusterServices.swift b/AppTests/Support/StubSettingsClusterServices.swift new file mode 100644 index 0000000..13320ef --- /dev/null +++ b/AppTests/Support/StubSettingsClusterServices.swift @@ -0,0 +1,217 @@ +// StubSettingsClusterServices +// +// Test doubles for the Settings-cluster services (work-consolidation.md +// G17-G20), so the panes' view models are driven without networking. +// +// Each stub follows `StubUserService`'s shape: a lock-guarded outcome queue, +// `@unchecked Sendable` because the lock provides the safety the compiler +// cannot see. + +import Foundation +import InterlinedDomain + +// MARK: - G19 sessions + +final class StubSessionsService: SessionsServicing, @unchecked Sendable { + + private let lock = NSLock() + private var listOutcomes: [Result<[ActiveSession], Error>] = [] + private var revokeOutcomes: [Result] = [] + private(set) var revokedIDs: [String] = [] + + func enqueueSessions(success: [ActiveSession]) { + lock.withLock { listOutcomes.append(.success(success)) } + } + + func enqueueSessions(failure: Error) { + lock.withLock { listOutcomes.append(.failure(failure)) } + } + + func enqueueRevoke(failure: Error? = nil) { + lock.withLock { revokeOutcomes.append(failure.map { .failure($0) } ?? .success(())) } + } + + func sessions() async throws -> [ActiveSession] { + try lock.withLock { + guard !listOutcomes.isEmpty else { return [] } + return try listOutcomes.removeFirst().get() + } + } + + func revoke(sessionID: String) async throws { + try lock.withLock { + revokedIDs.append(sessionID) + guard !revokeOutcomes.isEmpty else { return } + return try revokeOutcomes.removeFirst().get() + } + } +} + +// MARK: - G18 notification preferences + +final class StubNotificationPreferencesService: NotificationPreferencesServicing, @unchecked Sendable { + + private let lock = NSLock() + private var catalogueOutcomes: [Result<[NotificationEventPreference], Error>] = [] + private var updateOutcomes: [Result<[NotificationEventPreference], Error>] = [] + /// What the last `update` was asked to write — lets a test assert that only + /// changed events are sent. + private(set) var lastUpdatePayload: [NotificationEventPreference] = [] + + func enqueueCatalogue(success: [NotificationEventPreference]) { + lock.withLock { catalogueOutcomes.append(.success(success)) } + } + + func enqueueCatalogue(failure: Error) { + lock.withLock { catalogueOutcomes.append(.failure(failure)) } + } + + func enqueueUpdate(success: [NotificationEventPreference]) { + lock.withLock { updateOutcomes.append(.success(success)) } + } + + func enqueueUpdate(failure: Error) { + lock.withLock { updateOutcomes.append(.failure(failure)) } + } + + func catalogue() async throws -> [NotificationEventPreference] { + try lock.withLock { + guard !catalogueOutcomes.isEmpty else { return [] } + return try catalogueOutcomes.removeFirst().get() + } + } + + func update(_ events: [NotificationEventPreference]) async throws -> [NotificationEventPreference] { + try lock.withLock { + lastUpdatePayload = events + guard !updateOutcomes.isEmpty else { return events } + return try updateOutcomes.removeFirst().get() + } + } +} + +// MARK: - G17 app settings + device registry + +final class StubAppSettingsService: AppSettingsServicing, @unchecked Sendable { + + private let lock = NSLock() + private var deviceOutcomes: [Result<[AppDevice], Error>] = [] + private var mutationOutcomes: [Result] = [] + private var deregisterOutcomes: [Result] = [] + private(set) var deregisteredIDs: [String] = [] + private(set) var renamedTo: [String: String] = [:] + private(set) var promotedIDs: [String] = [] + + func enqueueDevices(success: [AppDevice]) { + lock.withLock { deviceOutcomes.append(.success(success)) } + } + + func enqueueDevices(failure: Error) { + lock.withLock { deviceOutcomes.append(.failure(failure)) } + } + + func enqueueMutation(success: AppDevice) { + lock.withLock { mutationOutcomes.append(.success(success)) } + } + + func enqueueMutation(failure: Error) { + lock.withLock { mutationOutcomes.append(.failure(failure)) } + } + + func enqueueDeregister(failure: Error? = nil) { + lock.withLock { deregisterOutcomes.append(failure.map { .failure($0) } ?? .success(())) } + } + + // MARK: Settings surface — unused by the Devices pane, minimally satisfied. + + func bootstrap(deviceID: String) async throws -> AppSettingsSnapshot { AppSettingsSnapshot() } + func sharedSettings() async throws -> AppSettingsBag { AppSettingsBag() } + func writeSharedSettings(_ bag: AppSettingsBag) async throws -> AppSettingsBag { bag } + func deviceSettings(deviceID: String) async throws -> AppSettingsBag { AppSettingsBag() } + func writeDeviceSettings(_ bag: AppSettingsBag, deviceID: String) async throws -> AppSettingsBag { bag } + + // MARK: Device registry + + func devices() async throws -> [AppDevice] { + try lock.withLock { + guard !deviceOutcomes.isEmpty else { return [] } + return try deviceOutcomes.removeFirst().get() + } + } + + func registerDevice(deviceID: String, name: String?) async throws -> AppDevice { + try lock.withLock { + guard !mutationOutcomes.isEmpty else { + return AppDevice(id: deviceID, name: name ?? deviceID) + } + return try mutationOutcomes.removeFirst().get() + } + } + + func renameDevice(deviceID: String, to name: String) async throws -> AppDevice { + try lock.withLock { + renamedTo[deviceID] = name + guard !mutationOutcomes.isEmpty else { return AppDevice(id: deviceID, name: name) } + return try mutationOutcomes.removeFirst().get() + } + } + + func makeMainWorkstation(deviceID: String) async throws -> AppDevice { + try lock.withLock { + promotedIDs.append(deviceID) + guard !mutationOutcomes.isEmpty else { + return AppDevice(id: deviceID, name: deviceID, isMainWorkstation: true) + } + return try mutationOutcomes.removeFirst().get() + } + } + + func deregisterDevice(deviceID: String) async throws { + try lock.withLock { + deregisteredIDs.append(deviceID) + guard !deregisterOutcomes.isEmpty else { return } + return try deregisterOutcomes.removeFirst().get() + } + } +} + +// MARK: - G20 tags + +final class StubTagsService: TagsServicing, @unchecked Sendable { + + private let lock = NSLock() + private var trendingOutcomes: [Result<[TrendingTag], Error>] = [] + private var suggestionOutcomes: [Result<[String], Error>] = [] + private(set) var requestedPrefixes: [String] = [] + + func enqueueTrending(success: [TrendingTag]) { + lock.withLock { trendingOutcomes.append(.success(success)) } + } + + func enqueueTrending(failure: Error) { + lock.withLock { trendingOutcomes.append(.failure(failure)) } + } + + func enqueueSuggestions(success: [String]) { + lock.withLock { suggestionOutcomes.append(.success(success)) } + } + + func enqueueSuggestions(failure: Error) { + lock.withLock { suggestionOutcomes.append(.failure(failure)) } + } + + func trending(limit: Int?) async throws -> [TrendingTag] { + try lock.withLock { + guard !trendingOutcomes.isEmpty else { return [] } + return try trendingOutcomes.removeFirst().get() + } + } + + func suggestions(prefix: String, limit: Int?) async throws -> [String] { + try lock.withLock { + requestedPrefixes.append(prefix) + guard !suggestionOutcomes.isEmpty else { return [] } + return try suggestionOutcomes.removeFirst().get() + } + } +} diff --git a/AppTests/TagCompletionViewModelTests.swift b/AppTests/TagCompletionViewModelTests.swift new file mode 100644 index 0000000..c174316 --- /dev/null +++ b/AppTests/TagCompletionViewModelTests.swift @@ -0,0 +1,146 @@ +// TagCompletionViewModelTests +// +// BDD-named tests for the composer's tag completion (work-consolidation.md G20), +// including the token parsing that decides *which* fragment gets completed. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class TagCompletionViewModelTests: XCTestCase { + + /// Zero debounce so tests do not wait on the real 200ms. + private func makeViewModel(_ stub: StubTagsService?) -> TagCompletionViewModel { + TagCompletionViewModel(service: stub, debounce: .zero) + } + + /// Lets the debounced lookup task run to completion. + private func settle() async { + for _ in 0..<10 { await Task.yield() } + } + + // MARK: - Token parsing + + func test_givenPartialToken_whenParsing_thenActiveTokenIsTheTrailingFragment() { + XCTAssertEqual(TagCompletionViewModel.activeToken(in: "swift ui swi"), "swi") + XCTAssertEqual(TagCompletionViewModel.activeToken(in: "swift, ui, swi"), "swi") + XCTAssertEqual(TagCompletionViewModel.activeToken(in: "#swi"), "swi", "a leading # is stripped") + } + + func test_givenTrailingSeparator_whenParsing_thenNothingIsBeingTyped() { + XCTAssertEqual(TagCompletionViewModel.activeToken(in: "swift "), "") + XCTAssertEqual(TagCompletionViewModel.activeToken(in: "swift,"), "") + XCTAssertEqual(TagCompletionViewModel.activeToken(in: ""), "") + } + + func test_givenMixedInput_whenParsing_thenCommittedTokensExcludeTheOneBeingTyped() { + XCTAssertEqual(TagCompletionViewModel.committedTokens(in: "swift ui swi"), ["swift", "ui"]) + // A trailing separator means every token is committed. + XCTAssertEqual(TagCompletionViewModel.committedTokens(in: "swift ui "), ["swift", "ui"]) + XCTAssertEqual(TagCompletionViewModel.committedTokens(in: "#swift, #ui, sw"), ["swift", "ui"]) + } + + // MARK: - Happy path + + func test_givenPrefix_whenTyping_thenFetchesAndShowsSuggestions() async { + let stub = StubTagsService() + stub.enqueueSuggestions(success: ["swift", "swiftui"]) + let viewModel = makeViewModel(stub) + + viewModel.input(changed: "swi") + await settle() + + XCTAssertEqual(viewModel.suggestions, ["swift", "swiftui"]) + XCTAssertTrue(viewModel.isShowing) + XCTAssertEqual(stub.requestedPrefixes, ["swi"]) + } + + func test_givenSuggestion_whenApplied_thenReplacesTheTypedTokenAndLeavesTrailingSpace() { + let viewModel = makeViewModel(StubTagsService()) + + let result = viewModel.apply("swiftui", to: "swift swi") + + XCTAssertEqual(result, "swift swiftui ") + XCTAssertFalse(viewModel.isShowing, "applying dismisses the popover") + } + + // MARK: - Invalid input + + func test_givenShortOrEmptyPrefix_whenTyping_thenSkipsTheLookup() async { + let stub = StubTagsService() + let viewModel = makeViewModel(stub) + + viewModel.input(changed: "s") + await settle() + viewModel.input(changed: "swift ") + await settle() + + XCTAssertTrue(stub.requestedPrefixes.isEmpty, + "a 1-character prefix matches most of the corpus; a trailing separator means nothing is being typed") + XCTAssertFalse(viewModel.isShowing) + } + + func test_givenAlreadyCommittedTag_whenSuggested_thenFiltersItOut() async { + let stub = StubTagsService() + stub.enqueueSuggestions(success: ["swift", "swiftui"]) + let viewModel = makeViewModel(stub) + + // "swift" is already in the field, so re-suggesting it is noise. + viewModel.input(changed: "Swift swi") + await settle() + + XCTAssertEqual(viewModel.suggestions, ["swiftui"], "match is case-insensitive") + } + + func test_givenNoService_whenTyping_thenStaysSilent() async { + let viewModel = makeViewModel(nil) + + viewModel.input(changed: "swi") + await settle() + + XCTAssertFalse(viewModel.isShowing) + } + + // MARK: - Upstream failure + + func test_givenLookupFailure_whenTyping_thenShowsNothingWithoutInterrupting() async { + let stub = StubTagsService() + stub.enqueueSuggestions(failure: URLError(.timedOut)) + let viewModel = makeViewModel(stub) + + viewModel.input(changed: "swi") + await settle() + + // Completion is a nicety — a failed lookup must not surface an error + // the user cannot act on mid-composition. + XCTAssertTrue(viewModel.suggestions.isEmpty) + XCTAssertFalse(viewModel.isShowing) + } + + // MARK: - Empty / boundary + + func test_givenNoMatches_whenTyping_thenPopoverStaysHidden() async { + let stub = StubTagsService() + stub.enqueueSuggestions(success: []) + let viewModel = makeViewModel(stub) + + viewModel.input(changed: "zzz") + await settle() + + XCTAssertFalse(viewModel.isShowing) + } + + func test_givenDismiss_whenCalled_thenClearsSuggestions() async { + let stub = StubTagsService() + stub.enqueueSuggestions(success: ["swift"]) + let viewModel = makeViewModel(stub) + viewModel.input(changed: "swi") + await settle() + XCTAssertTrue(viewModel.isShowing) + + viewModel.dismiss() + + XCTAssertFalse(viewModel.isShowing) + } +} diff --git a/AppTests/TrendingTagsViewModelTests.swift b/AppTests/TrendingTagsViewModelTests.swift new file mode 100644 index 0000000..be1c1bf --- /dev/null +++ b/AppTests/TrendingTagsViewModelTests.swift @@ -0,0 +1,65 @@ +// TrendingTagsViewModelTests +// +// BDD-named tests for the timeline's trending-tags strip (work-consolidation.md G20). + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class TrendingTagsViewModelTests: XCTestCase { + + // MARK: - Happy path + + func test_givenTrendingTags_whenLoading_thenPopulatesAndBecomesVisible() async { + let stub = StubTagsService() + stub.enqueueTrending(success: [ + TrendingTag(name: "swift", count: 42), + TrendingTag(name: "swiftui", count: 17) + ]) + let viewModel = TrendingTagsViewModel(service: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.tags.map(\.name), ["swift", "swiftui"]) + XCTAssertTrue(viewModel.isVisible) + } + + // MARK: - Invalid / unavailable + + func test_givenNoService_whenLoading_thenStaysHidden() async { + let viewModel = TrendingTagsViewModel(service: nil) + + await viewModel.load() + + XCTAssertFalse(viewModel.isVisible) + XCTAssertTrue(viewModel.tags.isEmpty) + } + + // MARK: - Upstream failure + + func test_givenFailure_whenLoading_thenHidesInsteadOfSurfacingAnError() async { + let stub = StubTagsService() + stub.enqueueTrending(failure: URLError(.notConnectedToInternet)) + let viewModel = TrendingTagsViewModel(service: stub) + + await viewModel.load() + + // Ambient decoration over the real feed must not push an error in front + // of the user's timeline. + XCTAssertFalse(viewModel.isVisible) + XCTAssertTrue(viewModel.tags.isEmpty) + } + + // MARK: - Empty / boundary + + func test_givenNoTrendingTags_whenLoading_thenStripStaysHidden() async { + let stub = StubTagsService() + stub.enqueueTrending(success: []) + let viewModel = TrendingTagsViewModel(service: stub) + + await viewModel.load() + + XCTAssertFalse(viewModel.isVisible, "an empty strip must cost no vertical space") + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ActiveSession.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ActiveSession.swift new file mode 100644 index 0000000..3b2a1f0 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ActiveSession.swift @@ -0,0 +1,47 @@ +import Foundation +import InterlinedKit + +/// One active sign-in / issued token on the account (work-consolidation.md G19). +/// +/// Drives Settings ▸ Security, where each row can be revoked. `isCurrent` +/// marks the session this app is running on — the UI must not offer to revoke +/// it without warning, since doing so signs the user out. +public struct ActiveSession: Sendable, Equatable, Identifiable { + public let id: String + /// Human label for the machine, e.g. "MacBook Pro". Falls back to a generic + /// string when the server omits it, so a row always has something to show. + public let deviceLabel: String + public let createdAt: Date? + public let lastUsedAt: Date? + /// Whether this row is the session the app is currently using. + public let isCurrent: Bool + + public init( + id: String, + deviceLabel: String, + createdAt: Date? = nil, + lastUsedAt: Date? = nil, + isCurrent: Bool = false + ) { + self.id = id + self.deviceLabel = deviceLabel + self.createdAt = createdAt + self.lastUsedAt = lastUsedAt + self.isCurrent = isCurrent + } +} + +extension ActiveSession { + /// Maps the DTO. A missing label collapses to "Unknown device" and a missing + /// `isCurrent` to `false` — the safe default, since it only ever *enables* + /// the revoke affordance. + public init(from dto: SessionDTO) { + self.init( + id: dto.id, + deviceLabel: dto.deviceLabel ?? "Unknown device", + createdAt: dto.createdAt, + lastUsedAt: dto.lastUsedAt, + isCurrent: dto.isCurrent ?? false + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AppSettingsBag.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AppSettingsBag.swift new file mode 100644 index 0000000..c3174c6 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AppSettingsBag.swift @@ -0,0 +1,156 @@ +import Foundation +import InterlinedKit + +/// A typed façade over an app-settings payload (work-consolidation.md G17). +/// +/// The platform stores our settings as an opaque blob and hands it back +/// verbatim, so the payload must survive a round trip **including keys this +/// build has never heard of** — otherwise an older client silently deletes a +/// newer client's settings the first time it saves. `AppSettingsBag` therefore +/// keeps the whole decoded payload and mutates it key-by-key. +/// +/// The raw storage is a Kit type and is deliberately `private`: per Decision +/// 0003 nothing in `App/Features/**` may import `InterlinedKit`, so the App +/// layer must be able to read and write settings without ever naming +/// `AppSettingsValue`. The typed subscripts below are that surface. +public struct AppSettingsBag: Sendable, Equatable { + + private var storage: [String: AppSettingsValue] + + public init() { self.storage = [:] } + + init(storage: [String: AppSettingsValue]) { self.storage = storage } + + /// Every key currently present, including ones this build does not + /// understand. Useful for diagnostics; not needed for normal reads. + public var keys: [String] { storage.keys.sorted() } + + public var isEmpty: Bool { storage.isEmpty } + + // MARK: - Typed access + // + // Each subscript reads through to the underlying value and returns nil when + // the key is absent *or* holds a different type — never traps, so a server + // that changes a field's type degrades to "unset" rather than crashing. + + public subscript(bool key: String) -> Bool? { + get { storage[key]?.boolValue } + set { write(key, newValue.map(AppSettingsValue.bool)) } + } + + public subscript(string key: String) -> String? { + get { storage[key]?.stringValue } + set { write(key, newValue.map(AppSettingsValue.string)) } + } + + public subscript(int key: String) -> Int? { + get { storage[key]?.intValue } + set { write(key, newValue.map { AppSettingsValue.number(Double($0)) }) } + } + + public subscript(double key: String) -> Double? { + get { storage[key]?.doubleValue } + set { write(key, newValue.map(AppSettingsValue.number)) } + } + + /// Removes a key entirely (distinct from setting it to `false` / `""`). + public mutating func remove(_ key: String) { storage.removeValue(forKey: key) } + + /// Setting a subscript to nil removes the key rather than storing a JSON + /// null, so "unset" round-trips as absence. + private mutating func write(_ key: String, _ value: AppSettingsValue?) { + if let value { + storage[key] = value + } else { + storage.removeValue(forKey: key) + } + } + + // MARK: - Kit boundary + + /// The payload as the wire type. Internal — only the services in this + /// module cross this boundary. + var payload: [String: AppSettingsValue] { storage } +} + +// MARK: - Devices + +/// A machine registered under the app key (work-consolidation.md G17). +public struct AppDevice: Sendable, Equatable, Identifiable { + public let id: String + /// Display name, falling back to the device id when unnamed. + public let name: String + /// The machine whose configuration seeds a brand-new device on first + /// sign-in. Exactly one device should carry this. + public let isMainWorkstation: Bool + public let createdAt: Date? + public let lastSeenAt: Date? + + public init( + id: String, + name: String, + isMainWorkstation: Bool = false, + createdAt: Date? = nil, + lastSeenAt: Date? = nil + ) { + self.id = id + self.name = name + self.isMainWorkstation = isMainWorkstation + self.createdAt = createdAt + self.lastSeenAt = lastSeenAt + } +} + +/// The result of the one-call launch bootstrap: account-wide settings plus this +/// machine's own, and whether the server had to seed a new device. +public struct AppSettingsSnapshot: Sendable, Equatable { + public var shared: AppSettingsBag + public var device: AppSettingsBag + /// True when the server had not seen this `deviceId` before. + public let isNewDevice: Bool + /// True when the new device's settings were seeded from the main + /// workstation — worth surfacing once, so the user knows why their new Mac + /// arrived pre-configured. + public let seededFromMainWorkstation: Bool + + public init( + shared: AppSettingsBag = AppSettingsBag(), + device: AppSettingsBag = AppSettingsBag(), + isNewDevice: Bool = false, + seededFromMainWorkstation: Bool = false + ) { + self.shared = shared + self.device = device + self.isNewDevice = isNewDevice + self.seededFromMainWorkstation = seededFromMainWorkstation + } +} + +// MARK: - Mapping + +extension AppSettingsBag { + init(from dto: AppSettingsDTO) { self.init(storage: dto.settings) } +} + +extension AppDevice { + public init(from dto: AppDeviceDTO) { + self.init( + id: dto.deviceId, + name: dto.name ?? dto.deviceId, + isMainWorkstation: dto.isMainWorkstation ?? false, + createdAt: dto.createdAt, + lastSeenAt: dto.lastSeenAt + ) + } +} + +extension AppSettingsSnapshot { + init(from dto: AppSettingsBootstrapDTO) { + self.init( + shared: AppSettingsBag(storage: dto.shared), + device: AppSettingsBag(storage: dto.device), + isNewDevice: dto.isNewDevice ?? false, + seededFromMainWorkstation: dto.seededFromMainWorkstation ?? false + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/NotificationEventPreference.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/NotificationEventPreference.swift new file mode 100644 index 0000000..717ec67 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/NotificationEventPreference.swift @@ -0,0 +1,70 @@ +import Foundation +import InterlinedKit + +/// The delivery channels for one notification event (work-consolidation.md G18). +/// +/// A channel the server does not mention is `nil` rather than `false`, so the +/// pane can render only the channels this event actually supports instead of +/// showing a dead "Email" switch for an event that has no email delivery. +public struct NotificationChannels: Sendable, Equatable { + public var push: Bool? + public var inApp: Bool? + public var email: Bool? + + public init(push: Bool? = nil, inApp: Bool? = nil, email: Bool? = nil) { + self.push = push + self.inApp = inApp + self.email = email + } +} + +/// One row of the server-driven notification-preferences catalogue. +/// +/// Labels and descriptions come from the server, so a new event type appears in +/// the pane without a client release — that is the whole point of G18's +/// data-driven design. +public struct NotificationEventPreference: Sendable, Equatable, Identifiable { + /// Stable event key, e.g. `dig`. Also the identity. + public let key: String + /// Display label. Falls back to the key so an unlabelled event still + /// renders something recognisable rather than an empty row. + public let label: String + public let description: String? + public var channels: NotificationChannels + + public var id: String { key } + + public init( + key: String, + label: String, + description: String? = nil, + channels: NotificationChannels = NotificationChannels() + ) { + self.key = key + self.label = label + self.description = description + self.channels = channels + } +} + +extension NotificationChannels { + public init(from dto: NotificationChannelsDTO) { + self.init(push: dto.push, inApp: dto.inApp, email: dto.email) + } + + /// The write projection — `PATCH` carries only the channels. + var dto: NotificationChannelsDTO { + NotificationChannelsDTO(push: push, inApp: inApp, email: email) + } +} + +extension NotificationEventPreference { + public init(from dto: NotificationEventDTO) { + self.init( + key: dto.key, + label: dto.label ?? dto.key, + description: dto.description, + channels: dto.channels.map(NotificationChannels.init(from:)) ?? NotificationChannels() + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/TrendingTag.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/TrendingTag.swift new file mode 100644 index 0000000..4af78db --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/TrendingTag.swift @@ -0,0 +1,40 @@ +import Foundation +import InterlinedKit + +/// A tag with its recent usage count (work-consolidation.md G20). Feeds the +/// timeline's trending strip; `TagsService.suggestions` feeds the composer's +/// completion popover with bare names. +public struct TrendingTag: Sendable, Equatable, Identifiable { + /// The tag text without a leading `#`. + public let name: String + public let count: Int + public let lastUsedAt: Date? + + /// The tag name is the stable identity — the API has no separate tag id. + public var id: String { name } + + public init(name: String, count: Int = 0, lastUsedAt: Date? = nil) { + self.name = name + self.count = count + self.lastUsedAt = lastUsedAt + } +} + +extension TrendingTag { + /// Maps the DTO, normalising away a leading `#` so callers can render the + /// sigil themselves without risking a double `##`. + public init(from dto: TrendingTagDTO) { + self.init( + name: TrendingTag.normalise(dto.tag), + count: dto.count ?? 0, + lastUsedAt: dto.lastUsedAt + ) + } + + /// Strips a single leading `#` and surrounding whitespace. + static func normalise(_ raw: String) -> String { + var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("#") { trimmed.removeFirst() } + return trimmed + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift new file mode 100644 index 0000000..413284f --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift @@ -0,0 +1,130 @@ +import Foundation +import InterlinedKit + +/// The synced-settings + device-registry surface the App layer codes against +/// (work-consolidation.md G17). +/// +/// This is the sanctioned home for the app's preferences *and* the Document +/// Sync Agent's per-machine configuration, replacing purely local +/// `UserDefaults` state: **shared** settings follow the account to every +/// machine, **device** settings stay pinned to one computer. +public protocol AppSettingsServicing: Sendable { + /// The one launch call: shared + this machine's settings, seeding a new + /// device from the main workstation when the server has not seen it before. + func bootstrap(deviceID: String) async throws -> AppSettingsSnapshot + + func sharedSettings() async throws -> AppSettingsBag + func writeSharedSettings(_ bag: AppSettingsBag) async throws -> AppSettingsBag + + func deviceSettings(deviceID: String) async throws -> AppSettingsBag + func writeDeviceSettings(_ bag: AppSettingsBag, deviceID: String) async throws -> AppSettingsBag + + func devices() async throws -> [AppDevice] + func registerDevice(deviceID: String, name: String?) async throws -> AppDevice + func renameDevice(deviceID: String, to name: String) async throws -> AppDevice + func makeMainWorkstation(deviceID: String) async throws -> AppDevice + func deregisterDevice(deviceID: String) async throws +} + +/// Talks to `/api/user/app-settings/{appKey}/…`. +/// +/// ⚠️ The `appKey` must be **registered with the backend owner** before this +/// ships (stated prerequisite in the G17 definition). It is injected at the +/// composition root rather than hard-coded here, so changing it is a one-line +/// edit and tests can use their own key. +/// +/// ⚠️ The live response shapes are unverified — the DTOs decode tolerantly and +/// should be tightened after a live probe. +public final class AppSettingsService: AppSettingsServicing { + + private let api: APIClientProtocol + private let appKey: String + + public init(api: APIClientProtocol, appKey: String) { + self.api = api + self.appKey = appKey + } + + // MARK: - Bootstrap + + public func bootstrap(deviceID: String) async throws -> AppSettingsSnapshot { + let dto = try await api.send(AppSettings.bootstrap(appKey: appKey, deviceId: deviceID)) + return AppSettingsSnapshot(from: dto) + } + + // MARK: - Shared settings + + public func sharedSettings() async throws -> AppSettingsBag { + AppSettingsBag(from: try await api.send(AppSettings.shared(appKey: appKey))) + } + + public func writeSharedSettings(_ bag: AppSettingsBag) async throws -> AppSettingsBag { + let dto = try await api.send( + AppSettings.writeShared(appKey: appKey, WriteAppSettingsRequest(settings: bag.payload)) + ) + return AppSettingsBag(from: dto) + } + + // MARK: - Per-device settings + + public func deviceSettings(deviceID: String) async throws -> AppSettingsBag { + AppSettingsBag( + from: try await api.send(AppSettings.deviceSettings(appKey: appKey, deviceId: deviceID)) + ) + } + + public func writeDeviceSettings( + _ bag: AppSettingsBag, + deviceID: String + ) async throws -> AppSettingsBag { + let dto = try await api.send( + AppSettings.writeDeviceSettings( + appKey: appKey, + deviceId: deviceID, + WriteAppSettingsRequest(settings: bag.payload) + ) + ) + return AppSettingsBag(from: dto) + } + + // MARK: - Device registry + + public func devices() async throws -> [AppDevice] { + let response = try await api.send(AppSettings.devices(appKey: appKey)) + return response.devices.map(AppDevice.init(from:)) + } + + public func registerDevice(deviceID: String, name: String?) async throws -> AppDevice { + let dto = try await api.send( + AppSettings.registerDevice( + appKey: appKey, + RegisterDeviceRequest(deviceId: deviceID, name: name) + ) + ) + return AppDevice(from: dto) + } + + public func renameDevice(deviceID: String, to name: String) async throws -> AppDevice { + let dto = try await api.send( + AppSettings.updateDevice(appKey: appKey, deviceId: deviceID, UpdateDeviceRequest(name: name)) + ) + return AppDevice(from: dto) + } + + public func makeMainWorkstation(deviceID: String) async throws -> AppDevice { + // Sends only the flag — a PATCH that also carried `name` would clobber a + // rename made on another machine between this client's read and write. + let dto = try await api.send( + AppSettings.updateDevice( + appKey: appKey, + deviceId: deviceID, + UpdateDeviceRequest(isMainWorkstation: true) + ) + ) + return AppDevice(from: dto) + } + + public func deregisterDevice(deviceID: String) async throws { + try await api.sendVoid(AppSettings.deleteDevice(appKey: appKey, deviceId: deviceID)) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/NotificationPreferencesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/NotificationPreferencesService.swift new file mode 100644 index 0000000..aab0428 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/NotificationPreferencesService.swift @@ -0,0 +1,38 @@ +import Foundation +import InterlinedKit + +/// The notification-preferences surface the App layer codes against +/// (work-consolidation.md G18). +public protocol NotificationPreferencesServicing: Sendable { + /// The server-driven event catalogue. + func catalogue() async throws -> [NotificationEventPreference] + /// Writes the given events' channels and returns the server's updated + /// catalogue. + func update(_ events: [NotificationEventPreference]) async throws -> [NotificationEventPreference] +} + +/// Reads and writes `/api/user/notification-preferences`. +public final class NotificationPreferencesService: NotificationPreferencesServicing { + + private let api: APIClientProtocol + + public init(api: APIClientProtocol) { + self.api = api + } + + public func catalogue() async throws -> [NotificationEventPreference] { + let response = try await api.send(NotificationPreferences.get()) + return response.events.map(NotificationEventPreference.init(from:)) + } + + public func update( + _ events: [NotificationEventPreference] + ) async throws -> [NotificationEventPreference] { + // Only key + channels go back; labels and descriptions are server-owned. + let body = UpdateNotificationPreferencesRequest( + events: events.map { .init(key: $0.key, channels: $0.channels.dto) } + ) + let response = try await api.send(NotificationPreferences.update(body)) + return response.events.map(NotificationEventPreference.init(from:)) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SessionsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SessionsService.swift new file mode 100644 index 0000000..40f3100 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SessionsService.swift @@ -0,0 +1,36 @@ +import Foundation +import InterlinedKit + +/// The active-sessions surface the App layer codes against +/// (work-consolidation.md G19). +public protocol SessionsServicing: Sendable { + /// Every active session, current one included. + func sessions() async throws -> [ActiveSession] + /// Revokes one session by id. + func revoke(sessionID: String) async throws +} + +/// Reads `GET /api/user/sessions` and revokes via +/// `DELETE /api/user/sessions/{id}`. +/// +/// Unlike `ContentLimitsService` these calls **throw** rather than falling back: +/// a security pane that silently showed a stale or empty session list would be +/// actively misleading, so the caller must surface the failure. +public final class SessionsService: SessionsServicing { + + private let api: APIClientProtocol + + public init(api: APIClientProtocol) { + self.api = api + } + + public func sessions() async throws -> [ActiveSession] { + let response = try await api.send(Sessions.list()) + return response.sessions.map(ActiveSession.init(from:)) + } + + public func revoke(sessionID: String) async throws { + // `sendVoid` — the revoke response body carries nothing the client needs. + try await api.sendVoid(Sessions.revoke(id: sessionID)) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/TagsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/TagsService.swift new file mode 100644 index 0000000..5e7e253 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/TagsService.swift @@ -0,0 +1,43 @@ +import Foundation +import InterlinedKit + +/// The tag surface the App layer codes against (work-consolidation.md G20). +public protocol TagsServicing: Sendable { + /// Trending tags for the timeline strip. + func trending(limit: Int?) async throws -> [TrendingTag] + /// Prefix completions for the composer popover. + func suggestions(prefix: String, limit: Int?) async throws -> [String] +} + +public extension TagsServicing { + func trending() async throws -> [TrendingTag] { try await trending(limit: nil) } + func suggestions(prefix: String) async throws -> [String] { + try await suggestions(prefix: prefix, limit: nil) + } +} + +/// Reads `GET /api/tags/trending` and `GET /api/tags/autocomplete`. +public final class TagsService: TagsServicing { + + private let api: APIClientProtocol + + public init(api: APIClientProtocol) { + self.api = api + } + + public func trending(limit: Int?) async throws -> [TrendingTag] { + let response = try await api.send(Tags.trending(limit: limit)) + return response.tags.map(TrendingTag.init(from:)) + } + + public func suggestions(prefix: String, limit: Int?) async throws -> [String] { + // A blank prefix would ask the server to rank the entire tag corpus; + // short-circuit instead of issuing a pointless request. + let trimmed = prefix.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + let response = try await api.send( + Tags.autocomplete(prefix: TrendingTag.normalise(trimmed), limit: limit) + ) + return response.tags.map(TrendingTag.normalise) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift new file mode 100644 index 0000000..c619384 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift @@ -0,0 +1,164 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD coverage for `AppSettingsService`, `AppSettingsBag` and the device +/// registry (work-consolidation.md G17). +final class AppSettingsServiceTests: XCTestCase { + + private let appKey = "interlinedlist-macos" + + private func makeService(_ api: StubAPIClient) -> AppSettingsService { + AppSettingsService(api: api, appKey: appKey) + } + + // MARK: - Happy path + + func test_givenBootstrapBody_whenLaunching_thenSplitsSharedAndDeviceSettings() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + { "shared": { "theme": "dark", "postsPerPage": 25 }, + "device": { "syncFolder": "/Users/x/Notes" }, + "isNewDevice": true, "seededFromMainWorkstation": true } + """#) + let service = makeService(api) + + let snapshot = try await service.bootstrap(deviceID: "dev-1") + + XCTAssertEqual(snapshot.shared[string: "theme"], "dark") + XCTAssertEqual(snapshot.shared[int: "postsPerPage"], 25) + XCTAssertEqual(snapshot.device[string: "syncFolder"], "/Users/x/Notes") + XCTAssertTrue(snapshot.isNewDevice) + XCTAssertTrue(snapshot.seededFromMainWorkstation) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/user/app-settings/interlinedlist-macos/bootstrap") + XCTAssertEqual(recorded.first?.query["deviceId"], "dev-1") + } + + func test_givenDevices_whenListing_thenMapsRegistry() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + { "devices": [ { "deviceId": "dev-1", "name": "Studio Mac", "isMainWorkstation": true }, + { "deviceId": "dev-2" } ] } + """#) + let service = makeService(api) + + let devices = try await service.devices() + + XCTAssertEqual(devices.map(\.id), ["dev-1", "dev-2"]) + XCTAssertTrue(devices[0].isMainWorkstation) + // An unnamed device falls back to its id so a row is never blank. + XCTAssertEqual(devices[1].name, "dev-2") + XCTAssertFalse(devices[1].isMainWorkstation) + } + + func test_givenPromotion_whenMakingMainWorkstation_thenSendsOnlyTheFlag() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "deviceId": "dev-2", "isMainWorkstation": true }"#) + let service = makeService(api) + + let device = try await service.makeMainWorkstation(deviceID: "dev-2") + + XCTAssertTrue(device.isMainWorkstation) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "PATCH") + XCTAssertEqual(recorded.first?.path, "/api/user/app-settings/interlinedlist-macos/devices/dev-2") + } + + func test_givenDeviceID_whenDeregistering_thenSendsDelete() async throws { + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = makeService(api) + + try await service.deregisterDevice(deviceID: "dev-2") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + } + + // MARK: - Invalid / forward-compatibility + // + // The behaviour that matters most for synced settings: an older build must + // not delete keys written by a newer one. + + func test_givenUnknownKeys_whenEditingAndWritingBack_thenUnknownKeysSurvive() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + { "settings": { "theme": "dark", "futureFeature": { "nested": true } } } + """#) + await api.enqueue(json: #""" + { "settings": { "theme": "light", "futureFeature": { "nested": true } } } + """#) + let service = makeService(api) + + var bag = try await service.sharedSettings() + XCTAssertEqual(bag[string: "theme"], "dark") + bag[string: "theme"] = "light" + + let saved = try await service.writeSharedSettings(bag) + + XCTAssertEqual(saved[string: "theme"], "light") + XCTAssertTrue(saved.keys.contains("futureFeature"), + "a key this build does not understand must survive a save") + } + + func test_givenWrongTypeOrMissingKey_whenReading_thenReturnsNilRatherThanTrapping() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "settings": { "postsPerPage": "twenty-five" } }"#) + let service = makeService(api) + + let bag = try await service.sharedSettings() + + XCTAssertNil(bag[int: "postsPerPage"], "a type change degrades to unset") + XCTAssertEqual(bag[string: "postsPerPage"], "twenty-five") + XCTAssertNil(bag[bool: "neverSet"]) + } + + func test_givenNilAssignment_whenWriting_thenRemovesTheKey() { + var bag = AppSettingsBag() + bag[bool: "syncEnabled"] = true + XCTAssertEqual(bag[bool: "syncEnabled"], true) + + bag[bool: "syncEnabled"] = nil + + XCTAssertFalse(bag.keys.contains("syncEnabled"), "unset must round-trip as absence, not null") + XCTAssertTrue(bag.isEmpty) + } + + // MARK: - Upstream failure + + func test_givenUnregisteredAppKey_whenFetching_thenThrows() async { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "unknown app key")) + let service = makeService(api) + + do { + _ = try await service.sharedSettings() + XCTFail("An unregistered appKey must surface, not silently yield empty settings") + } catch { + // expected + } + } + + // MARK: - Empty / boundary + + func test_givenNoDevices_whenListing_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "devices": [] }"#) + let service = makeService(api) + + let devices = try await service.devices() + + XCTAssertTrue(devices.isEmpty) + } + + func test_givenEmptySettings_whenFetching_thenReturnsEmptyBag() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "settings": {} }"#) + let service = makeService(api) + + let bag = try await service.sharedSettings() + + XCTAssertTrue(bag.isEmpty) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/NotificationPreferencesServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/NotificationPreferencesServiceTests.swift new file mode 100644 index 0000000..bdef5c1 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/NotificationPreferencesServiceTests.swift @@ -0,0 +1,89 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD coverage for `NotificationPreferencesService` (work-consolidation.md G18). +final class NotificationPreferencesServiceTests: XCTestCase { + + // MARK: - Happy path + + func test_givenCatalogue_whenFetching_thenMapsServerDrivenLabels() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + { "events": [ + { "key": "dig", "label": "Digs on your messages", + "description": "When someone digs a message you wrote.", + "channels": { "push": true, "inApp": true } } ] } + """#) + let service = NotificationPreferencesService(api: api) + + let events = try await service.catalogue() + + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].key, "dig") + XCTAssertEqual(events[0].label, "Digs on your messages") + XCTAssertEqual(events[0].description, "When someone digs a message you wrote.") + XCTAssertEqual(events[0].channels.push, true) + // A channel the server never mentions stays nil, so the pane can hide it + // rather than render a dead switch. + XCTAssertNil(events[0].channels.email) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/user/notification-preferences") + } + + func test_givenEditedChannels_whenUpdating_thenPatchesAndReturnsServerCopy() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + { "events": [ { "key": "dig", "label": "Digs", "channels": { "push": false, "inApp": true } } ] } + """#) + let service = NotificationPreferencesService(api: api) + var event = NotificationEventPreference(key: "dig", label: "Digs") + event.channels.push = false + + let updated = try await service.update([event]) + + XCTAssertEqual(updated[0].channels.push, false) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "PATCH") + } + + // MARK: - Invalid / incomplete input + + func test_givenEventWithoutLabel_whenFetching_thenFallsBackToKey() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "events": [ { "key": "mention" } ] }"#) + let service = NotificationPreferencesService(api: api) + + let events = try await service.catalogue() + + XCTAssertEqual(events[0].label, "mention", "an unlabelled event must still render") + XCTAssertNil(events[0].channels.push) + } + + // MARK: - Upstream failure + + func test_givenServerFailure_whenFetching_thenThrows() async { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = NotificationPreferencesService(api: api) + + do { + _ = try await service.catalogue() + XCTFail("Expected the failure to propagate") + } catch { + // expected + } + } + + // MARK: - Empty / boundary + + func test_givenEmptyCatalogue_whenFetching_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "events": [] }"#) + let service = NotificationPreferencesService(api: api) + + let events = try await service.catalogue() + + XCTAssertTrue(events.isEmpty) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SessionsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SessionsServiceTests.swift new file mode 100644 index 0000000..05e4505 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SessionsServiceTests.swift @@ -0,0 +1,99 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD coverage for `SessionsService` + the `ActiveSession` mapper +/// (work-consolidation.md G19). +final class SessionsServiceTests: XCTestCase { + + // MARK: - Happy path + + func test_givenSessions_whenFetching_thenMapsEveryFieldAndHitsTheRoute() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + { "sessions": [ + { "id": "sess_1", "deviceLabel": "MacBook Pro", + "createdAt": "2026-09-01T10:00:00Z", + "lastUsedAt": "2026-09-05T18:22:00Z", "isCurrent": true } ] } + """#) + let service = SessionsService(api: api) + + let sessions = try await service.sessions() + + XCTAssertEqual(sessions.count, 1) + XCTAssertEqual(sessions[0].id, "sess_1") + XCTAssertEqual(sessions[0].deviceLabel, "MacBook Pro") + XCTAssertTrue(sessions[0].isCurrent) + XCTAssertNotNil(sessions[0].createdAt) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/user/sessions") + } + + func test_givenSessionID_whenRevoking_thenSendsDeleteToThatSession() async throws { + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = SessionsService(api: api) + + try await service.revoke(sessionID: "sess_9") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/user/sessions/sess_9") + XCTAssertEqual(recorded.first?.method, "DELETE") + } + + // MARK: - Invalid / incomplete input + + func test_givenSessionMissingLabelAndFlag_whenFetching_thenUsesSafeDefaults() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "sessions": [ { "id": "sess_2" } ] }"#) + let service = SessionsService(api: api) + + let sessions = try await service.sessions() + + // A missing label must still render, and a missing `isCurrent` must not + // accidentally mark a row as the current session. + XCTAssertEqual(sessions[0].deviceLabel, "Unknown device") + XCTAssertFalse(sessions[0].isCurrent) + XCTAssertNil(sessions[0].lastUsedAt) + } + + // MARK: - Upstream failure + + func test_givenServerFailure_whenFetching_thenThrows() async { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = SessionsService(api: api) + + do { + _ = try await service.sessions() + XCTFail("A security pane must surface the failure, not show a stale list") + } catch { + // expected + } + } + + func test_givenServerFailure_whenRevoking_thenThrows() async { + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "nope")) + let service = SessionsService(api: api) + + do { + try await service.revoke(sessionID: "sess_1") + XCTFail("Expected the revoke failure to propagate") + } catch { + // expected + } + } + + // MARK: - Empty / boundary + + func test_givenNoSessions_whenFetching_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "sessions": [] }"#) + let service = SessionsService(api: api) + + let sessions = try await service.sessions() + + XCTAssertTrue(sessions.isEmpty) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/TagsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/TagsServiceTests.swift new file mode 100644 index 0000000..acab479 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/TagsServiceTests.swift @@ -0,0 +1,92 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD coverage for `TagsService` + the `TrendingTag` mapper +/// (work-consolidation.md G20). +final class TagsServiceTests: XCTestCase { + + // MARK: - Happy path + + func test_givenTrendingBody_whenFetching_thenMapsNamesAndCounts() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + { "tags": [ { "tag": "swift", "count": 42, "lastUsedAt": "2026-09-05T12:00:00Z" }, + { "tag": "swiftui", "count": 17 } ] } + """#) + let service = TagsService(api: api) + + let tags = try await service.trending() + + XCTAssertEqual(tags.map(\.name), ["swift", "swiftui"]) + XCTAssertEqual(tags[0].count, 42) + XCTAssertNotNil(tags[0].lastUsedAt) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/tags/trending") + } + + func test_givenPrefix_whenSuggesting_thenReturnsNames() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "tags": [ "swift", "swiftui" ] }"#) + let service = TagsService(api: api) + + let names = try await service.suggestions(prefix: "swi") + + XCTAssertEqual(names, ["swift", "swiftui"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/tags/autocomplete") + } + + // MARK: - Invalid input + + func test_givenHashPrefixedTags_whenMapping_thenStripsLeadingHash() async throws { + let api = StubAPIClient() + // `##"…"##` because the payload contains `"#`, which would close a `#"…"#`. + await api.enqueue(json: ##"{ "tags": [ { "tag": "#swift" }, { "tag": " spaced " } ] }"##) + let service = TagsService(api: api) + + let tags = try await service.trending() + + // The UI renders its own `#`, so a server-supplied one must not survive. + XCTAssertEqual(tags.map(\.name), ["swift", "spaced"]) + XCTAssertEqual(tags[0].count, 0, "missing count collapses to 0") + } + + func test_givenBlankPrefix_whenSuggesting_thenSkipsTheRequestEntirely() async throws { + let api = StubAPIClient() + let service = TagsService(api: api) + + let names = try await service.suggestions(prefix: " ") + + XCTAssertTrue(names.isEmpty) + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "a blank prefix must not ask the server to rank every tag") + } + + // MARK: - Upstream failure + + func test_givenServerFailure_whenFetchingTrending_thenThrows() async { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 503, serverMessage: nil)) + let service = TagsService(api: api) + + do { + _ = try await service.trending() + XCTFail("Expected the failure to propagate") + } catch { + // expected + } + } + + // MARK: - Empty / boundary + + func test_givenNoTags_whenFetching_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{ "tags": [] }"#) + let service = TagsService(api: api) + + let tags = try await service.trending() + + XCTAssertTrue(tags.isEmpty) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/APIClient/JSONCoders.swift b/Packages/InterlinedKit/Sources/InterlinedKit/APIClient/JSONCoders.swift index 7308e1e..94a3846 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/APIClient/JSONCoders.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/APIClient/JSONCoders.swift @@ -40,6 +40,13 @@ public enum JSONCoders { return encoder } + /// Parses an ISO 8601 timestamp using the same two formats the decoder + /// accepts. Needed where a date arrives inside an opaque payload that + /// bypasses `dateDecodingStrategy` (see `AppSettingsDTO`). + public static func parseDate(_ string: String) -> Date? { + iso8601Fractional.date(from: string) ?? iso8601.date(from: string) + } + // MARK: - Internals nonisolated(unsafe) static let iso8601Fractional: ISO8601DateFormatter = { diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/AppSettingsDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/AppSettingsDTO.swift new file mode 100644 index 0000000..f6be2a9 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/AppSettingsDTO.swift @@ -0,0 +1,200 @@ +import Foundation + +// MARK: - Shared / per-device settings payloads + +/// The stored settings blob for an app key or a device (work-consolidation.md +/// G17). The platform treats the payload as opaque, so it is carried as +/// `[String: AppSettingsValue]` and never narrowed to a fixed struct — see `AppSettingsValue` +/// for why that matters for forward compatibility. +/// +/// ⚠️ **Wire shapes here are UNVERIFIED.** The gap definition names the routes +/// and the semantics (from `/help/app-settings`) but does not record response +/// bodies, and the test account has no registered `appKey` yet. Every envelope +/// below therefore decodes tolerantly: the payload is accepted either under a +/// `settings` key or as the bare top-level object, and every metadata field is +/// optional. Tighten these once a live probe confirms the real shapes. +public struct AppSettingsDTO: Decodable, Sendable, Equatable { + public let settings: [String: AppSettingsValue] + public let updatedAt: Date? + + public init(settings: [String: AppSettingsValue], updatedAt: Date? = nil) { + self.settings = settings + self.updatedAt = updatedAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if let nested = try? c.decodeIfPresent([String: AppSettingsValue].self, forKey: .settings) { + self.settings = nested + self.updatedAt = try? c.decodeIfPresent(Date.self, forKey: .updatedAt) + return + } + // Bare object: the whole body *is* the settings payload. Strip the + // metadata keys so they don't masquerade as settings. + let bare = (try? decoder.singleValueContainer().decode([String: AppSettingsValue].self)) ?? [:] + var stripped = bare + stripped.removeValue(forKey: CodingKeys.updatedAt.rawValue) + self.settings = stripped + // The bare path decodes through `AppSettingsValue`, which bypasses the + // shared date strategy, so parse the timestamp explicitly here. + self.updatedAt = bare[CodingKeys.updatedAt.rawValue]?.stringValue.flatMap(JSONCoders.parseDate) + } + + private enum CodingKeys: String, CodingKey { case settings, updatedAt } +} + +/// `PUT` body for shared or per-device settings — the payload, wrapped. +public struct WriteAppSettingsRequest: Encodable, Sendable, Equatable { + public let settings: [String: AppSettingsValue] + + public init(settings: [String: AppSettingsValue]) { self.settings = settings } +} + +// MARK: - Device registry + +/// One registered device (machine) under an app key. +/// +/// `isMainWorkstation` is the flag behind the "main workstation seeds a +/// brand-new device on first sign-in" behaviour described in the gap definition. +public struct AppDeviceDTO: Decodable, Sendable, Equatable { + public let deviceId: String + public let name: String? + public let isMainWorkstation: Bool? + public let createdAt: Date? + public let lastSeenAt: Date? + + public init( + deviceId: String, + name: String? = nil, + isMainWorkstation: Bool? = nil, + createdAt: Date? = nil, + lastSeenAt: Date? = nil + ) { + self.deviceId = deviceId + self.name = name + self.isMainWorkstation = isMainWorkstation + self.createdAt = createdAt + self.lastSeenAt = lastSeenAt + } + + /// Accepts `deviceId` or a plain `id`, and `name` or `deviceLabel` — the + /// two naming conventions already seen elsewhere on this API (`SessionDTO` + /// uses `deviceLabel`). + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let id = try c.decodeIfPresent(String.self, forKey: .deviceId) + ?? c.decodeIfPresent(String.self, forKey: .id) + guard let id else { + throw DecodingError.keyNotFound( + CodingKeys.deviceId, + .init(codingPath: decoder.codingPath, debugDescription: "device has neither deviceId nor id") + ) + } + self.deviceId = id + self.name = try c.decodeIfPresent(String.self, forKey: .name) + ?? c.decodeIfPresent(String.self, forKey: .deviceLabel) + self.isMainWorkstation = try c.decodeIfPresent(Bool.self, forKey: .isMainWorkstation) + ?? c.decodeIfPresent(Bool.self, forKey: .isMain) + self.createdAt = try c.decodeIfPresent(Date.self, forKey: .createdAt) + self.lastSeenAt = try c.decodeIfPresent(Date.self, forKey: .lastSeenAt) + } + + private enum CodingKeys: String, CodingKey { + case deviceId, id, name, deviceLabel, isMainWorkstation, isMain, createdAt, lastSeenAt + } +} + +/// `GET /api/user/app-settings/{appKey}/devices` response — named envelope or +/// bare array. +public struct AppDevicesResponse: Decodable, Sendable, Equatable { + public let devices: [AppDeviceDTO] + + public init(devices: [AppDeviceDTO]) { self.devices = devices } + + public init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer(), + let bare = try? single.decode([AppDeviceDTO].self) { + self.devices = bare + return + } + let c = try decoder.container(keyedBy: CodingKeys.self) + self.devices = try c.decodeIfPresent([AppDeviceDTO].self, forKey: .devices) ?? [] + } + + private enum CodingKeys: String, CodingKey { case devices } +} + +/// `POST …/devices` body — register this machine. +public struct RegisterDeviceRequest: Encodable, Sendable, Equatable { + public let deviceId: String + public let name: String? + + public init(deviceId: String, name: String? = nil) { + self.deviceId = deviceId + self.name = name + } +} + +/// `PATCH …/devices/{deviceId}` body — rename, or promote to main workstation. +/// Both fields are optional so a caller sends only what it is changing. +public struct UpdateDeviceRequest: Encodable, Sendable, Equatable { + public let name: String? + public let isMainWorkstation: Bool? + + public init(name: String? = nil, isMainWorkstation: Bool? = nil) { + self.name = name + self.isMainWorkstation = isMainWorkstation + } +} + +// MARK: - Bootstrap + +/// `GET …/bootstrap?deviceId=…` response — the one call a launching client makes. +/// +/// Carries the account-wide shared settings plus this machine's own settings; +/// when the device is brand new the server seeds the per-device payload from the +/// main workstation (the gap definition's stated behaviour), which is what +/// `seededFromMainWorkstation` reports. +public struct AppSettingsBootstrapDTO: Decodable, Sendable, Equatable { + public let shared: [String: AppSettingsValue] + public let device: [String: AppSettingsValue] + public let isNewDevice: Bool? + public let seededFromMainWorkstation: Bool? + + public init( + shared: [String: AppSettingsValue] = [:], + device: [String: AppSettingsValue] = [:], + isNewDevice: Bool? = nil, + seededFromMainWorkstation: Bool? = nil + ) { + self.shared = shared + self.device = device + self.isNewDevice = isNewDevice + self.seededFromMainWorkstation = seededFromMainWorkstation + } + + /// Accepts `shared`/`sharedSettings` and `device`/`deviceSettings`. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + // Split out of an inline `??` chain: the nested optionals from + // `try? decodeIfPresent` made the expression too costly to type-check. + func payload(_ primary: CodingKeys, _ alternate: CodingKeys) -> [String: AppSettingsValue] { + let type = [String: AppSettingsValue].self + if let found = try? c.decodeIfPresent(type, forKey: primary) { + return found + } + if let found = try? c.decodeIfPresent(type, forKey: alternate) { + return found + } + return [:] + } + self.shared = payload(.shared, .sharedSettings) + self.device = payload(.device, .deviceSettings) + self.isNewDevice = try? c.decodeIfPresent(Bool.self, forKey: .isNewDevice) + self.seededFromMainWorkstation = try? c.decodeIfPresent(Bool.self, forKey: .seededFromMainWorkstation) + } + + private enum CodingKeys: String, CodingKey { + case shared, sharedSettings, device, deviceSettings, isNewDevice, seededFromMainWorkstation + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/AppSettingsValue.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/AppSettingsValue.swift new file mode 100644 index 0000000..11b0c64 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/AppSettingsValue.swift @@ -0,0 +1,70 @@ +import Foundation + +/// A losslessly round-trippable JSON value for app-settings payloads. +/// +/// Named distinctly rather than `AppSettingsValue` because InterlinedKit already has +/// an internal `AppSettingsValue` (pagination-envelope probing) and a `ListJSONValue` +/// (list row cells); this follows that same per-domain precedent instead of +/// widening a shared type. +/// +/// Added for the app-settings surface (work-consolidation.md G17), where the +/// server stores an **opaque, app-defined settings blob**: the platform does not +/// know or validate our schema, it just persists what we PUT and returns it +/// unchanged. Modelling that as a concrete struct would silently drop any key +/// this client version does not know about — including keys written by a *newer* +/// build of the app on another machine, which is exactly the data synced +/// settings must not lose. +/// +/// `AppSettingsValue` therefore preserves the whole payload verbatim. The domain layer +/// projects the keys it understands out of it and writes them back into the +/// same container, leaving unknown keys untouched. +public enum AppSettingsValue: Codable, Sendable, Equatable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([AppSettingsValue]) + case object([String: AppSettingsValue]) + + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let v = try? c.decode(Bool.self) { self = .bool(v); return } + if let v = try? c.decode(Double.self) { self = .number(v); return } + if let v = try? c.decode(String.self) { self = .string(v); return } + if let v = try? c.decode([AppSettingsValue].self) { self = .array(v); return } + if let v = try? c.decode([String: AppSettingsValue].self) { self = .object(v); return } + throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unrecognised JSON value") + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .null: try c.encodeNil() + case .bool(let v): try c.encode(v) + case .number(let v): try c.encode(v) + case .string(let v): try c.encode(v) + case .array(let v): try c.encode(v) + case .object(let v): try c.encode(v) + } + } + + // MARK: - Typed accessors + // + // Convenience readers so the domain layer can pull known keys out of an + // opaque blob without pattern-matching at every call site. Each returns nil + // when the value is absent or of a different type — never traps. + + public var boolValue: Bool? { if case .bool(let v) = self { return v }; return nil } + public var stringValue: String? { if case .string(let v) = self { return v }; return nil } + public var intValue: Int? { if case .number(let v) = self { return Int(v) }; return nil } + public var doubleValue: Double? { if case .number(let v) = self { return v }; return nil } + public var objectValue: [String: AppSettingsValue]? { if case .object(let v) = self { return v }; return nil } + public var arrayValue: [AppSettingsValue]? { if case .array(let v) = self { return v }; return nil } + + /// Subscript into an object value. Returns nil for non-objects. + public subscript(key: String) -> AppSettingsValue? { + guard case .object(let dict) = self else { return nil } + return dict[key] + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/NotificationPreferencesDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/NotificationPreferencesDTO.swift new file mode 100644 index 0000000..cfd9ea9 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/NotificationPreferencesDTO.swift @@ -0,0 +1,93 @@ +import Foundation + +/// `GET /api/user/notification-preferences` response (work-consolidation.md G18) +/// — a **server-driven event catalogue**, so the pane renders itself from the +/// payload rather than hard-coding a list of switches: +/// +/// ```json +/// { "events": [ { "key": "dig", +/// "label": "Digs on your messages", +/// "description": "…", +/// "channels": { "push": true, "inApp": true } } ] } +/// ``` +/// +/// Only `key` is required — an event whose label the server has not filled in +/// still renders (the domain mapper falls back to the key), and an unknown +/// channel never breaks the decode. +public struct NotificationPreferencesResponse: Decodable, Sendable, Equatable { + public let events: [NotificationEventDTO] + + public init(events: [NotificationEventDTO]) { self.events = events } + + public init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer(), + let bare = try? single.decode([NotificationEventDTO].self) { + self.events = bare + return + } + let c = try decoder.container(keyedBy: CodingKeys.self) + self.events = try c.decodeIfPresent([NotificationEventDTO].self, forKey: .events) ?? [] + } + + private enum CodingKeys: String, CodingKey { case events } +} + +/// One notification event in the catalogue, with its per-channel toggles. +public struct NotificationEventDTO: Decodable, Sendable, Equatable { + public let key: String + public let label: String? + public let description: String? + public let channels: NotificationChannelsDTO? + + public init( + key: String, + label: String? = nil, + description: String? = nil, + channels: NotificationChannelsDTO? = nil + ) { + self.key = key + self.label = label + self.description = description + self.channels = channels + } +} + +/// The per-event delivery channels. `push` is the switchboard G9 (APNs) will +/// read once push ships. +public struct NotificationChannelsDTO: Codable, Sendable, Equatable { + public let push: Bool? + public let inApp: Bool? + public let email: Bool? + + public init(push: Bool? = nil, inApp: Bool? = nil, email: Bool? = nil) { + self.push = push + self.inApp = inApp + self.email = email + } +} + +// MARK: - Write + +/// `PATCH /api/user/notification-preferences` request body. +/// +/// The gap definition records that `PATCH` writes the catalogue but does not +/// pin the request shape. This mirrors the read envelope with only the fields a +/// write needs — the event `key` and its `channels` — which is the shape the +/// rest of this API uses for partial updates (send what changed, omit the +/// server-owned display fields). Labels and descriptions are server-owned and +/// deliberately not echoed back. +public struct UpdateNotificationPreferencesRequest: Encodable, Sendable, Equatable { + public let events: [EventUpdate] + + public init(events: [EventUpdate]) { self.events = events } + + public struct EventUpdate: Encodable, Sendable, Equatable { + public let key: String + public let channels: NotificationChannelsDTO + + public init(key: String, channels: NotificationChannelsDTO) { + self.key = key + self.channels = channels + } + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SessionDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SessionDTO.swift new file mode 100644 index 0000000..a34b9a8 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SessionDTO.swift @@ -0,0 +1,59 @@ +import Foundation + +/// `GET /api/user/sessions` response — the account's active sessions +/// (work-consolidation.md G19). Shape recorded in the gap definition: +/// +/// ```json +/// { "sessions": [ { "id": "sess_1", "deviceLabel": "MacBook Pro", +/// "createdAt": "2026-09-01T10:00:00Z", +/// "lastUsedAt": "2026-09-05T18:22:00Z", +/// "isCurrent": true } ] } +/// ``` +/// +/// Decodes the named envelope **or** a bare array, matching the tolerance the +/// GitHub DTOs adopted after the live API was found to wrap some collections +/// and not others. Every field but `id` is optional so an added, renamed, or +/// dropped field never fails the whole decode. +public struct SessionsResponse: Decodable, Sendable, Equatable { + public let sessions: [SessionDTO] + + public init(sessions: [SessionDTO]) { + self.sessions = sessions + } + + public init(from decoder: Decoder) throws { + // Bare array first: `[ {...}, {...} ]`. + if let single = try? decoder.singleValueContainer(), + let bare = try? single.decode([SessionDTO].self) { + self.sessions = bare + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + self.sessions = try container.decodeIfPresent([SessionDTO].self, forKey: .sessions) ?? [] + } + + private enum CodingKeys: String, CodingKey { case sessions } +} + +/// One active session / issued token. +public struct SessionDTO: Decodable, Sendable, Equatable { + public let id: String + public let deviceLabel: String? + public let createdAt: Date? + public let lastUsedAt: Date? + public let isCurrent: Bool? + + public init( + id: String, + deviceLabel: String? = nil, + createdAt: Date? = nil, + lastUsedAt: Date? = nil, + isCurrent: Bool? = nil + ) { + self.id = id + self.deviceLabel = deviceLabel + self.createdAt = createdAt + self.lastUsedAt = lastUsedAt + self.isCurrent = isCurrent + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift new file mode 100644 index 0000000..6b3c3d3 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift @@ -0,0 +1,71 @@ +import Foundation + +/// `GET /api/tags/trending` response (work-consolidation.md G20). Shape verified +/// in the gap definition: `{"tags":[{"tag","count","lastUsedAt"}…]}`. +/// +/// Tolerant of a bare array as well as the named envelope, and of a missing +/// `count` / `lastUsedAt`. +public struct TrendingTagsResponse: Decodable, Sendable, Equatable { + public let tags: [TrendingTagDTO] + + public init(tags: [TrendingTagDTO]) { self.tags = tags } + + public init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer(), + let bare = try? single.decode([TrendingTagDTO].self) { + self.tags = bare + return + } + let c = try decoder.container(keyedBy: CodingKeys.self) + self.tags = try c.decodeIfPresent([TrendingTagDTO].self, forKey: .tags) ?? [] + } + + private enum CodingKeys: String, CodingKey { case tags } +} + +/// One trending tag with its usage count. +public struct TrendingTagDTO: Decodable, Sendable, Equatable { + public let tag: String + public let count: Int? + public let lastUsedAt: Date? + + public init(tag: String, count: Int? = nil, lastUsedAt: Date? = nil) { + self.tag = tag + self.count = count + self.lastUsedAt = lastUsedAt + } +} + +/// `GET /api/tags/autocomplete` response — prefix matches over public messages. +/// +/// The gap definition does not pin this shape, so the decoder accepts the three +/// plausible forms rather than guessing one: a bare string array +/// (`["swift","swiftui"]`), a bare object array (`[{"tag":"swift"}]`), or either +/// wrapped under `tags`. All collapse to `[String]`. +public struct TagSuggestionsResponse: Decodable, Sendable, Equatable { + public let tags: [String] + + public init(tags: [String]) { self.tags = tags } + + public init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer() { + if let strings = try? single.decode([String].self) { + self.tags = strings + return + } + if let objects = try? single.decode([TrendingTagDTO].self) { + self.tags = objects.map(\.tag) + return + } + } + let c = try decoder.container(keyedBy: CodingKeys.self) + if let strings = try? c.decodeIfPresent([String].self, forKey: .tags) ?? nil { + self.tags = strings + return + } + let objects = try c.decodeIfPresent([TrendingTagDTO].self, forKey: .tags) ?? [] + self.tags = objects.map(\.tag) + } + + private enum CodingKeys: String, CodingKey { case tags } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AppSettingsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AppSettingsEndpoint.swift new file mode 100644 index 0000000..e47c583 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AppSettingsEndpoint.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Request builders for **Applications: synced settings + device registry** +/// (work-consolidation.md G17) — the platform's own mechanism for companion +/// apps, and the sanctioned home for this app's preferences *and* the Document +/// Sync Agent's per-machine configuration, replacing purely local +/// `UserDefaults` state. +/// +/// The model, per `/help/app-settings`: +/// - **shared settings** follow the account to every machine; +/// - **per-device settings** stay pinned to one computer; +/// - one machine is the **main workstation**, whose config seeds a brand-new +/// device on first sign-in; +/// - devices can be renamed or deregistered. +/// +/// ⚠️ **The `appKey` must be registered with the backend owner before this +/// ships** (stated prerequisite in the gap definition). `AppSettings` takes the +/// key as a parameter rather than hard-coding one, so registering a different +/// key later is a one-line change at the composition root. +/// +/// ⚠️ Response shapes are **unverified** — see the note on `AppSettingsDTO`. +public enum AppSettings { + + // MARK: - Shared (account-wide) settings + + /// `GET /api/user/app-settings/{appKey}` — the account-wide shared settings. + public static func shared(appKey: String) -> Request { + Request(method: .get, path: "/api/user/app-settings/\(appKey)", auth: .bearer) + } + + /// `PUT /api/user/app-settings/{appKey}` — replace the shared settings. + public static func writeShared( + appKey: String, + _ body: WriteAppSettingsRequest + ) -> Request { + Request( + method: .put, + path: "/api/user/app-settings/\(appKey)", + body: .json(body), + auth: .bearer + ) + } + + /// `DELETE /api/user/app-settings/{appKey}` — drop all settings for the app. + public static func deleteShared(appKey: String) -> Request { + Request(method: .delete, path: "/api/user/app-settings/\(appKey)", auth: .bearer) + } + + // MARK: - Bootstrap + + /// `GET /api/user/app-settings/{appKey}/bootstrap?deviceId=…` — the single + /// launch call: shared settings + this machine's settings, seeding a new + /// device from the main workstation. + public static func bootstrap(appKey: String, deviceId: String) -> Request { + Request( + method: .get, + path: "/api/user/app-settings/\(appKey)/bootstrap", + query: [.string("deviceId", deviceId)], + auth: .bearer + ) + } + + // MARK: - Device registry + + /// `GET /api/user/app-settings/{appKey}/devices` — every machine registered + /// under this app key. + public static func devices(appKey: String) -> Request { + Request(method: .get, path: "/api/user/app-settings/\(appKey)/devices", auth: .bearer) + } + + /// `POST /api/user/app-settings/{appKey}/devices` — register this machine. + public static func registerDevice( + appKey: String, + _ body: RegisterDeviceRequest + ) -> Request { + Request( + method: .post, + path: "/api/user/app-settings/\(appKey)/devices", + body: .json(body), + auth: .bearer + ) + } + + /// `PATCH /api/user/app-settings/{appKey}/devices/{deviceId}` — rename a + /// device or promote it to main workstation. + public static func updateDevice( + appKey: String, + deviceId: String, + _ body: UpdateDeviceRequest + ) -> Request { + Request( + method: .patch, + path: "/api/user/app-settings/\(appKey)/devices/\(deviceId)", + body: .json(body), + auth: .bearer + ) + } + + /// `DELETE /api/user/app-settings/{appKey}/devices/{deviceId}` — deregister. + public static func deleteDevice(appKey: String, deviceId: String) -> Request { + Request( + method: .delete, + path: "/api/user/app-settings/\(appKey)/devices/\(deviceId)", + auth: .bearer + ) + } + + // MARK: - Per-device settings + + /// `GET …/devices/{deviceId}/settings` — one machine's pinned settings. + public static func deviceSettings(appKey: String, deviceId: String) -> Request { + Request( + method: .get, + path: "/api/user/app-settings/\(appKey)/devices/\(deviceId)/settings", + auth: .bearer + ) + } + + /// `PUT …/devices/{deviceId}/settings` — replace one machine's settings. + public static func writeDeviceSettings( + appKey: String, + deviceId: String, + _ body: WriteAppSettingsRequest + ) -> Request { + Request( + method: .put, + path: "/api/user/app-settings/\(appKey)/devices/\(deviceId)/settings", + body: .json(body), + auth: .bearer + ) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationPreferencesEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationPreferencesEndpoint.swift new file mode 100644 index 0000000..addd720 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationPreferencesEndpoint.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Request builders for **notification preferences** (work-consolidation.md G18) +/// — the Settings ▸ Notifications pane. +/// +/// The read returns a typed event catalogue with server-driven labels and +/// descriptions, so the pane is data-driven: new event types appear without a +/// client release. The per-event `channels.push` flags are the switchboard +/// G9 push will consume. +public enum NotificationPreferences { + + /// `GET /api/user/notification-preferences` — the event catalogue. + public static func get() -> Request { + Request(method: .get, path: "/api/user/notification-preferences", auth: .bearer) + } + + /// `PATCH /api/user/notification-preferences` — write changed channels. + /// + /// Returns the updated catalogue so the caller can re-render from the + /// server's authoritative copy rather than trusting its local edit. + public static func update( + _ body: UpdateNotificationPreferencesRequest + ) -> Request { + Request( + method: .patch, + path: "/api/user/notification-preferences", + body: .json(body), + auth: .bearer + ) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SessionsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SessionsEndpoint.swift new file mode 100644 index 0000000..0ad2e03 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SessionsEndpoint.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Request builders for **active sessions & token revocation** +/// (work-consolidation.md G19) — the Settings ▸ Security pane. +/// +/// `GET /api/user/sessions` is recorded in the gap definition as +/// **Bearer-reachable** (unlike the session-cookie-only `/api/auth/accounts` +/// that blocks G10 multi-account), so both builders use `.bearer`. +public enum Sessions { + + /// `GET /api/user/sessions` — the account's active sessions. + public static func list() -> Request { + Request(method: .get, path: "/api/user/sessions", auth: .bearer) + } + + /// `DELETE /api/user/sessions/{id}` — revoke one session. + /// + /// Returns `EmptyResponse` because the revoke body carries nothing the + /// client needs; callers use `sendVoid` and re-read the list. + public static func revoke(id: String) -> Request { + Request(method: .delete, path: "/api/user/sessions/\(id)", auth: .bearer) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/TagsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/TagsEndpoint.swift new file mode 100644 index 0000000..7dca070 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/TagsEndpoint.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Request builders for **tag trending + autocomplete** (work-consolidation.md +/// G20) — feeds the timeline's trending strip and the composer's tag-completion +/// popover. +/// +/// `GET /api/tags/trending` has a verified response shape; `GET +/// /api/tags/autocomplete` does not, so its DTO decodes tolerantly (see +/// `TagSuggestionsResponse`). +public enum Tags { + + /// `GET /api/tags/trending` — most-used tags, newest usage first. + /// + /// - Parameter limit: optional server-side cap. Dropped when nil. + public static func trending(limit: Int? = nil) -> Request { + Request( + method: .get, + path: "/api/tags/trending", + query: [.int("limit", limit)], + auth: .bearer + ) + } + + /// `GET /api/tags/autocomplete?q=…` — prefix match over public messages. + /// + /// The gap definition names the route as a prefix match but not its query + /// key; `q` matches every other search route on this API + /// (`/api/messages/search`, `/api/lists/search`, `/api/documents/search`). + public static func autocomplete(prefix: String, limit: Int? = nil) -> Request { + Request( + method: .get, + path: "/api/tags/autocomplete", + query: [.string("q", prefix), .int("limit", limit)], + auth: .bearer + ) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/AppSettingsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/AppSettingsEndpointTests.swift new file mode 100644 index 0000000..470d27a --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/AppSettingsEndpointTests.swift @@ -0,0 +1,186 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the app-settings + device-registry endpoints +/// (work-consolidation.md G17). +/// +/// The live response shapes are unverified, so the decode tests deliberately +/// cover *both* envelope conventions this API has been seen to use (named key +/// vs. bare) rather than pinning one guess. +final class AppSettingsEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + private let appKey = "interlinedlist-macos" + + private func makeClient() -> (APIClient, StubHTTPDataTransport) { + let transport = StubHTTPDataTransport() + let auth = DefaultAuthTransport( + tokenStore: InMemoryTokenStore(initial: "il_tok_abc"), + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + return (APIClient(baseURL: baseURL, transport: transport, authTransport: auth), transport) + } + + // MARK: - Builder shape + + func test_givenBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(AppSettings.shared(appKey: appKey).path, "/api/user/app-settings/interlinedlist-macos") + XCTAssertEqual(AppSettings.shared(appKey: appKey).method, .get) + + let write = AppSettings.writeShared(appKey: appKey, WriteAppSettingsRequest(settings: [:])) + XCTAssertEqual(write.method, .put) + + XCTAssertEqual(AppSettings.deleteShared(appKey: appKey).method, .delete) + + let bootstrap = AppSettings.bootstrap(appKey: appKey, deviceId: "dev-1") + XCTAssertEqual(bootstrap.path, "/api/user/app-settings/interlinedlist-macos/bootstrap") + XCTAssertTrue(bootstrap.query.contains(.string("deviceId", "dev-1"))) + + XCTAssertEqual(AppSettings.devices(appKey: appKey).path, "/api/user/app-settings/interlinedlist-macos/devices") + XCTAssertEqual(AppSettings.registerDevice(appKey: appKey, RegisterDeviceRequest(deviceId: "dev-1")).method, .post) + XCTAssertEqual(AppSettings.updateDevice(appKey: appKey, deviceId: "dev-1", UpdateDeviceRequest(name: "Mac")).method, .patch) + XCTAssertEqual(AppSettings.deleteDevice(appKey: appKey, deviceId: "dev-1").method, .delete) + XCTAssertEqual( + AppSettings.deviceSettings(appKey: appKey, deviceId: "dev-1").path, + "/api/user/app-settings/interlinedlist-macos/devices/dev-1/settings" + ) + XCTAssertEqual( + AppSettings.writeDeviceSettings(appKey: appKey, deviceId: "dev-1", WriteAppSettingsRequest(settings: [:])).method, + .put + ) + // Every builder is Bearer. + XCTAssertEqual(AppSettings.devices(appKey: appKey).auth, .bearer) + } + + // MARK: - Happy path + + func test_givenWrappedSettings_whenSharedSent_thenDecodesPayload() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + { "settings": { "theme": "dark", "postsPerPage": 25, "syncEnabled": true }, + "updatedAt": "2026-09-05T10:00:00Z" } + """#)) + + let dto = try await client.send(AppSettings.shared(appKey: appKey)) + + XCTAssertEqual(dto.settings["theme"]?.stringValue, "dark") + XCTAssertEqual(dto.settings["postsPerPage"]?.intValue, 25) + XCTAssertEqual(dto.settings["syncEnabled"]?.boolValue, true) + XCTAssertEqual(dto.updatedAt, JSONCoders.parseDate("2026-09-05T10:00:00Z")) + } + + func test_givenDevicesBody_whenSent_thenDecodesRegistry() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + { "devices": [ + { "deviceId": "dev-1", "name": "Studio Mac", "isMainWorkstation": true, + "lastSeenAt": "2026-09-05T09:00:00Z" }, + { "id": "dev-2", "deviceLabel": "Laptop", "isMain": false } + ] } + """#)) + + let response = try await client.send(AppSettings.devices(appKey: appKey)) + + XCTAssertEqual(response.devices.count, 2) + XCTAssertEqual(response.devices[0].deviceId, "dev-1") + XCTAssertEqual(response.devices[0].isMainWorkstation, true) + // Second row uses the alternate `id` / `deviceLabel` / `isMain` spelling. + XCTAssertEqual(response.devices[1].deviceId, "dev-2") + XCTAssertEqual(response.devices[1].name, "Laptop") + XCTAssertEqual(response.devices[1].isMainWorkstation, false) + } + + func test_givenBootstrapBody_whenSent_thenSplitsSharedAndDeviceSettings() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + { "shared": { "theme": "dark" }, + "device": { "syncFolder": "/Users/x/Notes" }, + "isNewDevice": true, "seededFromMainWorkstation": true } + """#)) + + let dto = try await client.send(AppSettings.bootstrap(appKey: appKey, deviceId: "dev-9")) + + XCTAssertEqual(dto.shared["theme"]?.stringValue, "dark") + XCTAssertEqual(dto.device["syncFolder"]?.stringValue, "/Users/x/Notes") + XCTAssertEqual(dto.isNewDevice, true) + XCTAssertEqual(dto.seededFromMainWorkstation, true) + } + + // MARK: - Invalid / tolerant input + + func test_givenBareSettingsObject_whenSharedSent_thenTreatsBodyAsPayload() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "theme": "light", "updatedAt": "2026-09-05T10:00:00Z" }"#)) + + let dto = try await client.send(AppSettings.shared(appKey: appKey)) + + XCTAssertEqual(dto.settings["theme"]?.stringValue, "light") + // Metadata must not leak into the settings payload. + XCTAssertNil(dto.settings["updatedAt"]) + XCTAssertEqual(dto.updatedAt, JSONCoders.parseDate("2026-09-05T10:00:00Z")) + } + + func test_givenAlternateBootstrapKeys_whenSent_thenStillDecodes() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "sharedSettings": { "a": 1 }, "deviceSettings": { "b": 2 } }"#)) + + let dto = try await client.send(AppSettings.bootstrap(appKey: appKey, deviceId: "dev-9")) + + XCTAssertEqual(dto.shared["a"]?.intValue, 1) + XCTAssertEqual(dto.device["b"]?.intValue, 2) + } + + func test_givenNestedAndUnknownKeys_whenRoundTripped_thenPreservedVerbatim() throws { + // The forward-compatibility guarantee: a payload written by a newer + // build must survive decode -> encode unchanged. + let raw = #"{ "settings": { "known": true, "futureFeature": { "nested": [1, "two", null] } } }"# + let dto = try JSONDecoder().decode(AppSettingsDTO.self, from: Data(raw.utf8)) + + let reEncoded = try JSONEncoder().encode(WriteAppSettingsRequest(settings: dto.settings)) + let round = try JSONDecoder().decode(AppSettingsDTO.self, from: reEncoded) + + XCTAssertEqual(round.settings["known"]?.boolValue, true) + let nested = round.settings["futureFeature"]?["nested"]?.arrayValue + XCTAssertEqual(nested?.count, 3) + XCTAssertEqual(nested?[1].stringValue, "two") + XCTAssertEqual(nested?[2], .null) + } + + // MARK: - API failure + + func test_givenUnregisteredAppKey_whenSent_thenThrowsNotFoundWithMessage() async throws { + // 404 maps to `.notFound`, not `.httpStatus` — the client narrows the + // well-known statuses and carries the server's message through. + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"unknown app key"}"#, status: 404)) + + do { + _ = try await client.send(AppSettings.shared(appKey: "not-registered")) + XCTFail("Expected notFound") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "unknown app key")) + } + } + + // MARK: - Empty / boundary + + func test_givenNoDevices_whenSent_thenReturnsEmptyRegistry() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "devices": [] }"#)) + + let response = try await client.send(AppSettings.devices(appKey: appKey)) + + XCTAssertTrue(response.devices.isEmpty) + } + + func test_givenEmptySettings_whenSent_thenDecodesEmptyPayload() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "settings": {} }"#)) + + let dto = try await client.send(AppSettings.shared(appKey: appKey)) + + XCTAssertTrue(dto.settings.isEmpty) + XCTAssertNil(dto.updatedAt) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/NotificationPreferencesEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/NotificationPreferencesEndpointTests.swift new file mode 100644 index 0000000..f3a2243 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/NotificationPreferencesEndpointTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the notification-preferences endpoint (work-consolidation.md G18). +final class NotificationPreferencesEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient() -> (APIClient, StubHTTPDataTransport) { + let transport = StubHTTPDataTransport() + let auth = DefaultAuthTransport( + tokenStore: InMemoryTokenStore(initial: "il_tok_abc"), + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + return (APIClient(baseURL: baseURL, transport: transport, authTransport: auth), transport) + } + + // MARK: - Builder shape + + func test_givenBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + let get = NotificationPreferences.get() + XCTAssertEqual(get.method, .get) + XCTAssertEqual(get.path, "/api/user/notification-preferences") + XCTAssertEqual(get.auth, .bearer) + + let patch = NotificationPreferences.update( + UpdateNotificationPreferencesRequest(events: [ + .init(key: "dig", channels: NotificationChannelsDTO(push: false, inApp: true)) + ]) + ) + XCTAssertEqual(patch.method, .patch) + XCTAssertEqual(patch.path, "/api/user/notification-preferences") + XCTAssertEqual(patch.auth, .bearer) + } + + // MARK: - Happy path + + func test_givenCatalogueBody_whenSent_thenDecodesLabelsAndChannels() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + { "events": [ + { "key": "dig", "label": "Digs on your messages", + "description": "When someone digs a message you wrote.", + "channels": { "push": true, "inApp": true } }, + { "key": "follow", "label": "New followers", + "channels": { "push": false, "inApp": true, "email": true } } + ] } + """#)) + + let response = try await client.send(NotificationPreferences.get()) + + XCTAssertEqual(response.events.count, 2) + XCTAssertEqual(response.events[0].key, "dig") + XCTAssertEqual(response.events[0].label, "Digs on your messages") + XCTAssertEqual(response.events[0].channels?.push, true) + XCTAssertNil(response.events[1].description) + XCTAssertEqual(response.events[1].channels?.email, true) + } + + // MARK: - Invalid / tolerant input + + func test_givenEventMissingLabelAndChannels_whenSent_thenStillDecodes() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "events": [ { "key": "mention" } ] }"#)) + + let response = try await client.send(NotificationPreferences.get()) + + XCTAssertEqual(response.events.map(\.key), ["mention"]) + XCTAssertNil(response.events[0].label) + XCTAssertNil(response.events[0].channels) + } + + func test_givenUpdateBody_whenEncoded_thenCarriesOnlyKeyAndChannels() throws { + let body = UpdateNotificationPreferencesRequest(events: [ + .init(key: "dig", channels: NotificationChannelsDTO(push: false, inApp: true)) + ]) + + let json = try JSONSerialization.jsonObject( + with: JSONEncoder().encode(body) + ) as? [String: Any] + let events = json?["events"] as? [[String: Any]] + + XCTAssertEqual(events?.count, 1) + XCTAssertEqual(events?[0]["key"] as? String, "dig") + XCTAssertNotNil(events?[0]["channels"]) + // Server-owned display fields must never be echoed back. + XCTAssertNil(events?[0]["label"]) + XCTAssertNil(events?[0]["description"]) + } + + // MARK: - API failure + + func test_givenServerError_whenSent_thenThrowsHttpStatus() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"nope"}"#, status: 500)) + + do { + _ = try await client.send(NotificationPreferences.get()) + XCTFail("Expected httpStatus") + } catch let error as APIError { + guard case .httpStatus(let code, _) = error else { + return XCTFail("Expected .httpStatus, got \(error)") + } + XCTAssertEqual(code, 500) + } + } + + // MARK: - Empty / boundary + + func test_givenNoEvents_whenSent_thenReturnsEmptyCatalogue() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "events": [] }"#)) + + let response = try await client.send(NotificationPreferences.get()) + + XCTAssertTrue(response.events.isEmpty) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/SessionsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/SessionsEndpointTests.swift new file mode 100644 index 0000000..7dbdbd9 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/SessionsEndpointTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the Sessions endpoint (work-consolidation.md G19). +final class SessionsEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient() -> (APIClient, StubHTTPDataTransport) { + let transport = StubHTTPDataTransport() + let auth = DefaultAuthTransport( + tokenStore: InMemoryTokenStore(initial: "il_tok_abc"), + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + return (APIClient(baseURL: baseURL, transport: transport, authTransport: auth), transport) + } + + // MARK: - Builder shape + + func test_givenSessionBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + let list = Sessions.list() + XCTAssertEqual(list.method, .get) + XCTAssertEqual(list.path, "/api/user/sessions") + XCTAssertEqual(list.auth, .bearer) + + let revoke = Sessions.revoke(id: "sess_9") + XCTAssertEqual(revoke.method, .delete) + XCTAssertEqual(revoke.path, "/api/user/sessions/sess_9") + XCTAssertEqual(revoke.auth, .bearer) + } + + // MARK: - Happy path + + func test_givenSessionsBody_whenSent_thenDecodesEveryField() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + { "sessions": [ + { "id": "sess_1", "deviceLabel": "MacBook Pro", + "createdAt": "2026-09-01T10:00:00Z", + "lastUsedAt": "2026-09-05T18:22:00Z", "isCurrent": true }, + { "id": "sess_2", "deviceLabel": "iPhone", "isCurrent": false } + ] } + """#)) + + let response = try await client.send(Sessions.list()) + + XCTAssertEqual(response.sessions.count, 2) + XCTAssertEqual(response.sessions[0].id, "sess_1") + XCTAssertEqual(response.sessions[0].deviceLabel, "MacBook Pro") + XCTAssertEqual(response.sessions[0].isCurrent, true) + XCTAssertEqual(response.sessions[1].id, "sess_2") + XCTAssertNil(response.sessions[1].createdAt) + } + + // MARK: - Invalid / tolerant input + + func test_givenBareArray_whenSent_thenStillDecodes() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"[ { "id": "sess_3" } ]"#)) + + let response = try await client.send(Sessions.list()) + + XCTAssertEqual(response.sessions.map(\.id), ["sess_3"]) + XCTAssertNil(response.sessions[0].deviceLabel) + } + + // MARK: - API failure + + func test_givenServerError_whenListSent_thenThrowsHttpStatus() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"boom"}"#, status: 500)) + + do { + _ = try await client.send(Sessions.list()) + XCTFail("Expected httpStatus") + } catch let error as APIError { + guard case .httpStatus(let code, _) = error else { + return XCTFail("Expected .httpStatus, got \(error)") + } + XCTAssertEqual(code, 500) + } + } + + // MARK: - Empty / boundary + + func test_givenNoSessions_whenSent_thenReturnsEmptyList() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "sessions": [] }"#)) + + let response = try await client.send(Sessions.list()) + + XCTAssertTrue(response.sessions.isEmpty) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/TagsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/TagsEndpointTests.swift new file mode 100644 index 0000000..9df5df4 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/TagsEndpointTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the Tags endpoints (work-consolidation.md G20). +final class TagsEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient() -> (APIClient, StubHTTPDataTransport) { + let transport = StubHTTPDataTransport() + let auth = DefaultAuthTransport( + tokenStore: InMemoryTokenStore(initial: "il_tok_abc"), + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + return (APIClient(baseURL: baseURL, transport: transport, authTransport: auth), transport) + } + + // MARK: - Builder shape + + func test_givenTagBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + let trending = Tags.trending() + XCTAssertEqual(trending.method, .get) + XCTAssertEqual(trending.path, "/api/tags/trending") + XCTAssertEqual(trending.auth, .bearer) + // A nil limit is kept as a valueless QueryItem and dropped when the + // URL is built, so assert on the value rather than on emptiness. + XCTAssertNil(trending.query.first { $0.name == "limit" }?.value) + + let autocomplete = Tags.autocomplete(prefix: "swi", limit: 5) + XCTAssertEqual(autocomplete.method, .get) + XCTAssertEqual(autocomplete.path, "/api/tags/autocomplete") + XCTAssertEqual(autocomplete.auth, .bearer) + } + + func test_givenLimit_whenTrendingBuilt_thenCarriesLimitQuery() { + // Query assertions go against the builder, matching SharingEndpointTests. + XCTAssertTrue(Tags.trending(limit: 10).query.contains(.int("limit", 10))) + XCTAssertTrue(Tags.autocomplete(prefix: "swi").query.contains(.string("q", "swi"))) + } + + // MARK: - Happy path + + func test_givenTrendingBody_whenSent_thenDecodesTagsAndCounts() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + { "tags": [ { "tag": "swift", "count": 42, "lastUsedAt": "2026-09-05T12:00:00Z" }, + { "tag": "swiftui", "count": 17 } ] } + """#)) + + let response = try await client.send(Tags.trending()) + + XCTAssertEqual(response.tags.map(\.tag), ["swift", "swiftui"]) + XCTAssertEqual(response.tags[0].count, 42) + XCTAssertNil(response.tags[1].lastUsedAt) + } + + // MARK: - Invalid / tolerant input + // + // The autocomplete shape is unpinned in the gap definition, so all three + // plausible encodings must collapse to the same `[String]`. + + func test_givenBareStringArray_whenAutocompleteSent_thenDecodes() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"[ "swift", "swiftui" ]"#)) + + let response = try await client.send(Tags.autocomplete(prefix: "swi")) + + XCTAssertEqual(response.tags, ["swift", "swiftui"]) + } + + func test_givenWrappedObjectArray_whenAutocompleteSent_thenDecodesToTagNames() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "tags": [ { "tag": "swift" }, { "tag": "swiftui" } ] }"#)) + + let response = try await client.send(Tags.autocomplete(prefix: "swi")) + + XCTAssertEqual(response.tags, ["swift", "swiftui"]) + } + + // MARK: - API failure + + func test_givenServerError_whenTrendingSent_thenThrowsHttpStatus() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"nope"}"#, status: 503)) + + do { + _ = try await client.send(Tags.trending()) + XCTFail("Expected httpStatus") + } catch let error as APIError { + guard case .httpStatus(let code, _) = error else { + return XCTFail("Expected .httpStatus, got \(error)") + } + XCTAssertEqual(code, 503) + } + } + + // MARK: - Empty / boundary + + func test_givenNoTags_whenSent_thenReturnsEmpty() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{ "tags": [] }"#)) + + let response = try await client.send(Tags.trending()) + + XCTAssertTrue(response.tags.isEmpty) + } +}