import CryptoKit import XCTest import Foundation import AppKit @testable import ClipBored final class ClipboardStoreTests: XCTestCase { private var defaults: UserDefaults! private var defaultsSuiteName: String! private var cacheService: ClipboardCacheService! private var baseURL: URL! override func setUpWithError() throws { try super.setUpWithError() defaultsSuiteName = "com.clipbored.teststore.\(UUID().uuidString)" defaults = UserDefaults(suiteName: defaultsSuiteName) defaults.removePersistentDomain(forName: defaultsSuiteName) baseURL = FileManager.default.temporaryDirectory .appendingPathComponent("clipboredtests", isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService()) } override func tearDownWithError() throws { defaults.removePersistentDomain(forName: defaultsSuiteName) if let baseURL { try? FileManager.default.removeItem(at: baseURL) } cacheService = nil defaults = nil baseURL = nil try super.tearDownWithError() } func testUpsertMovesDuplicateToFrontAndPersists() throws { let settings = makeSettings(maxHistory: 4) let store = makeStore(settings: settings) let start = Date() store.upsert(makeItem("one", displayText: "One", created: start)) store.upsert(makeItem("two", displayText: "Two", created: start.addingTimeInterval(-10))) store.upsert(makeItem("three", displayText: "Three", created: start.addingTimeInterval(-20))) store.upsert(makeItem("one", displayText: "One (updated)", created: start.addingTimeInterval(-30))) store.flushPersistenceForTesting() XCTAssertEqual(store.items.count, 3) XCTAssertEqual(store.items.map(\.payload), ["one", "three", "two"]) XCTAssertEqual(store.items.first?.useCount, 2) let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.count, 3) XCTAssertEqual(restored.items.first?.payload, "one") XCTAssertEqual(restored.items.first?.useCount, 2) } func testHistoryLimitIsEnforcedByOverflowPurge() throws { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) let start = Date() (0...50).forEach { i in store.upsert(makeItem("item-\(i)", displayText: "\(i)", created: start.addingTimeInterval(-Double(i)))) } store.flushPersistenceForTesting() XCTAssertEqual(store.items.count, 50) XCTAssertEqual(store.items.first?.payload, "item-50") XCTAssertEqual(store.items.last?.payload, "item-1") let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.count, 50) XCTAssertFalse(restored.items.contains(where: { $0.payload == "item-0" })) XCTAssertTrue(restored.items.contains(where: { $0.payload == "item-50" })) XCTAssertTrue(restored.items.contains(where: { $0.payload == "item-1" })) } func testMarkUsedUpdatesStateAndWritesMutationOnlyOnce() { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) store.upsert(makeItem("same", displayText: "First", created: Date())) store.upsert(makeItem("same", displayText: "First duplicate", created: Date().addingTimeInterval(1))) store.flushPersistenceForTesting() let first = try! XCTUnwrap(store.items.first) let firstID = first.id store.markUsed(firstID) store.flushPersistenceForTesting() let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.count, 1) XCTAssertEqual(restored.items.first?.useCount, 3) XCTAssertEqual(restored.items.first?.displayText, "First duplicate") } func testTogglePinPersistsAcrossReload() { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) store.upsert(makeItem("alpha", displayText: "A", created: Date())) store.flushPersistenceForTesting() let itemID = try! XCTUnwrap(store.items.first?.id) store.togglePin(itemID) store.flushPersistenceForTesting() let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.first?.isPinned, true) } func testSetCollectionPersistsAcrossReload() { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) store.upsert(makeItem("alpha", displayText: "A", created: Date())) store.flushPersistenceForTesting() let itemID = try! XCTUnwrap(store.items.first?.id) store.setCollection(itemID, name: " Client Work ") store.flushPersistenceForTesting() let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.first?.collectionName, "Client Work") let restoredID = try! XCTUnwrap(restored.items.first?.id) restored.setCollection(restoredID, name: nil) restored.flushPersistenceForTesting() let cleared = makeStore(settings: settings) cleared.flushPersistenceForTesting() XCTAssertNil(cleared.items.first?.collectionName) } func testUpdateTextPersistsAcrossReloadAndPreservesMetadata() { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) var item = makeItem("alpha", displayText: "Alpha", created: Date(timeIntervalSince1970: 100)) item.isPinned = true item.collectionName = "Important Notes" item.sourceApp = "Notes" item.useCount = 4 store.upsert(item) store.flushPersistenceForTesting() let itemID = try! XCTUnwrap(store.items.first?.id) XCTAssertTrue(store.updateText(itemID, text: "Edited alpha")) store.flushPersistenceForTesting() let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() let restoredItem = try! XCTUnwrap(restored.items.first) XCTAssertEqual(restoredItem.id, itemID) XCTAssertEqual(restoredItem.kind, .text) XCTAssertEqual(restoredItem.displayText, "Edited alpha") XCTAssertEqual(restoredItem.payload, "Edited alpha") XCTAssertEqual(restoredItem.payloadHash, restored.hashString("Edited alpha")) XCTAssertEqual(restoredItem.isPinned, true) XCTAssertEqual(restoredItem.collectionName, "Important Notes") XCTAssertEqual(restoredItem.sourceApp, "Notes") XCTAssertEqual(restoredItem.useCount, 4) } func testUpdateTextRejectsNonTextItems() { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) let pdf = makePDFItem(path: "/tmp/report.pdf", hash: "pdf-hash", created: Date(timeIntervalSince1970: 100)) store.upsert(pdf) store.flushPersistenceForTesting() let itemID = try! XCTUnwrap(store.items.first?.id) XCTAssertFalse(store.updateText(itemID, text: "Edited")) XCTAssertEqual(store.items.first?.payload, "/tmp/report.pdf") XCTAssertEqual(store.items.first?.payloadHash, "pdf-hash") } func testLegacyJSONHistoryMigratesToSQLite() throws { let settings = makeSettings(maxHistory: 50) let itemID = UUID() let legacyJSON = """ [ { "id": "\(itemID.uuidString)", "kind": 0, "displayText": "Legacy Note", "payload": "legacy payload", "payloadHash": "legacy-hash", "createdAt": "2026-06-27T12:00:00Z", "lastUsedAt": "2026-06-27T12:01:00Z", "useCount": 3, "sourceApp": "Notes", "imagePath": null, "thumbnailPath": null } ] """ try legacyJSON.data(using: .utf8)!.write(to: baseURL.appendingPathComponent("history.json")) let store = makeStore(settings: settings) store.flushPersistenceForTesting() XCTAssertEqual(store.items.count, 1) XCTAssertEqual(store.items.first?.id, itemID) XCTAssertEqual(store.items.first?.payload, "legacy payload") XCTAssertEqual(store.items.first?.useCount, 3) let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.first?.payload, "legacy payload") } func testPinnedItemsSurviveNormalHistoryPrune() { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) let start = Date() store.upsert(makeItem("pinned-old", displayText: "Pinned", created: start.addingTimeInterval(-500))) let pinnedID = try! XCTUnwrap(store.items.first?.id) store.togglePin(pinnedID) (0..<60).forEach { index in store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index)))) } store.flushPersistenceForTesting() XCTAssertTrue(store.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned })) XCTAssertEqual(store.items.filter { !$0.isPinned }.count, 50) XCTAssertEqual(store.items.filter(\.isPinned).count, 1) let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned })) } func testStorageFilesUsePrivatePermissions() throws { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) store.upsert(makeItem("private", displayText: "Private", created: Date())) store.flushPersistenceForTesting() XCTAssertEqual(try posixPermissions(baseURL), 0o700) XCTAssertEqual(try posixPermissions(baseURL.appendingPathComponent("history.sqlite")), 0o600) } func testStorageDirectoryHonorsEnvironmentOverride() throws { let overrideURL = baseURL.appendingPathComponent("OverrideStorage", isDirectory: true) setenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey, overrideURL.path, 1) defer { unsetenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey) } let resolved = ClipboardStore.storageDirectory() XCTAssertEqual(resolved.path, overrideURL.standardizedFileURL.path) XCTAssertTrue(FileManager.default.fileExists(atPath: overrideURL.path)) XCTAssertEqual(try posixPermissions(overrideURL), 0o700) } func testRemoveAllResetsEncryptionKeyAfterClearingDatabase() throws { let settings = makeSettings(maxHistory: 50) var resetCount = 0 let keyData = Data(repeating: 7, count: 32) let encryptionService = ClipboardEncryptionService( keyProvider: { SymmetricKey(data: keyData) }, resetProvider: { resetCount += 1 } ) let store = makeStore(settings: settings, encryptionService: encryptionService) store.upsert(makeItem("first", displayText: "First", created: Date())) store.upsert(makeItem("second", displayText: "Second", created: Date())) store.flushPersistenceForTesting() let firstID = try XCTUnwrap(store.items.first?.id) store.remove(firstID) store.flushPersistenceForTesting() XCTAssertEqual(resetCount, 0) store.removeAll() store.flushPersistenceForTesting() XCTAssertEqual(resetCount, 1) XCTAssertTrue(store.items.isEmpty) let restored = makeStore(settings: settings, encryptionService: encryptionService) restored.flushPersistenceForTesting() XCTAssertTrue(restored.items.isEmpty) } func testRemoveAllCompactsDatabaseFile() throws { let settings = makeSettings(maxHistory: 2000) settings.pruneDuplicates = false let store = makeStore(settings: settings) let payload = String(repeating: "clipbored database compaction payload ", count: 180) for index in 0..<60 { store.upsert(makeItem("\(payload)\(index)", displayText: "Large \(index)", created: Date(timeIntervalSince1970: Double(index)))) } store.flushPersistenceForTesting() let dbURL = baseURL.appendingPathComponent("history.sqlite") let sizeBeforeClear = try fileSize(dbURL) XCTAssertGreaterThan(sizeBeforeClear, 200_000) store.removeAll() store.flushPersistenceForTesting() let sizeAfterClear = try fileSize(dbURL) XCTAssertLessThan(sizeAfterClear, sizeBeforeClear / 2) XCTAssertEqual(try posixPermissions(dbURL), 0o600) } func testPersistedTextFieldsAreEncryptedAndReload() throws { let settings = makeSettings(maxHistory: 50) let encryptionService = fixedEncryptionService() let store = makeStore(settings: settings, encryptionService: encryptionService) let item = ClipboardItem( id: UUID(), kind: .text, displayText: "Displayed secret \(UUID().uuidString)", payload: "Payload secret \(UUID().uuidString)", payloadHash: "Hash secret \(UUID().uuidString)", createdAt: Date(timeIntervalSince1970: 100), lastUsedAt: Date(timeIntervalSince1970: 100), useCount: 1, sourceApp: "Secret app \(UUID().uuidString)", imagePath: nil, thumbnailPath: nil, isPinned: false, sourceAppBundleId: "com.example.secret.\(UUID().uuidString)", ocrText: "OCR secret \(UUID().uuidString)", collectionName: "Collection secret \(UUID().uuidString)" ) store.upsert(item) store.flushPersistenceForTesting() let rawDatabaseText = try databaseText() XCTAssertTrue(rawDatabaseText.contains(ClipboardEncryptionService.marker)) XCTAssertFalse(rawDatabaseText.contains(item.displayText)) XCTAssertFalse(rawDatabaseText.contains(item.payload)) XCTAssertFalse(rawDatabaseText.contains(item.payloadHash)) XCTAssertFalse(rawDatabaseText.contains(item.sourceApp!)) XCTAssertFalse(rawDatabaseText.contains(item.sourceAppBundleId!)) XCTAssertFalse(rawDatabaseText.contains(item.ocrText!)) XCTAssertFalse(rawDatabaseText.contains(item.collectionName!)) let restored = makeStore(settings: settings, encryptionService: encryptionService) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.first?.displayText, item.displayText) XCTAssertEqual(restored.items.first?.payload, item.payload) XCTAssertEqual(restored.items.first?.payloadHash, item.payloadHash) XCTAssertEqual(restored.items.first?.sourceApp, item.sourceApp) XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId) XCTAssertEqual(restored.items.first?.ocrText, item.ocrText) XCTAssertEqual(restored.items.first?.collectionName, item.collectionName) } func testPlaintextDatabaseMigratesToEncryptedFieldsOnLoad() throws { let settings = makeSettings(maxHistory: 50) let plaintextStore = makeStore(settings: settings, encryptionService: noOpEncryptionService()) let item = ClipboardItem( id: UUID(), kind: .text, displayText: "Legacy display \(UUID().uuidString)", payload: "Legacy payload \(UUID().uuidString)", payloadHash: "Legacy hash \(UUID().uuidString)", createdAt: Date(timeIntervalSince1970: 200), lastUsedAt: Date(timeIntervalSince1970: 200), useCount: 1, sourceApp: "Legacy source \(UUID().uuidString)", imagePath: nil, thumbnailPath: nil, isPinned: false, sourceAppBundleId: "com.example.legacy.\(UUID().uuidString)", ocrText: "Legacy OCR \(UUID().uuidString)", collectionName: "Legacy collection \(UUID().uuidString)" ) plaintextStore.upsert(item) plaintextStore.flushPersistenceForTesting() XCTAssertTrue(try databaseText().contains(item.payload)) let encryptionService = fixedEncryptionService() let restored = makeStore(settings: settings, encryptionService: encryptionService) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.first?.displayText, item.displayText) XCTAssertEqual(restored.items.first?.payload, item.payload) XCTAssertEqual(restored.items.first?.payloadHash, item.payloadHash) XCTAssertEqual(restored.items.first?.sourceApp, item.sourceApp) XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId) XCTAssertEqual(restored.items.first?.ocrText, item.ocrText) XCTAssertEqual(restored.items.first?.collectionName, item.collectionName) let migratedDatabaseText = try databaseText() XCTAssertTrue(migratedDatabaseText.contains(ClipboardEncryptionService.marker)) XCTAssertFalse(migratedDatabaseText.contains(item.displayText)) XCTAssertFalse(migratedDatabaseText.contains(item.payload)) XCTAssertFalse(migratedDatabaseText.contains(item.payloadHash)) XCTAssertFalse(migratedDatabaseText.contains(item.sourceApp!)) XCTAssertFalse(migratedDatabaseText.contains(item.sourceAppBundleId!)) XCTAssertFalse(migratedDatabaseText.contains(item.ocrText!)) XCTAssertFalse(migratedDatabaseText.contains(item.collectionName!)) } func testDuplicatePDFReplacementRemovesOldAttachment() throws { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) let hash = "same-pdf" let oldPath = try XCTUnwrap(cacheService.cachePDF(Data("old".utf8), id: UUID())) let newPath = try XCTUnwrap(cacheService.cachePDF(Data("new".utf8), id: UUID())) store.upsert(makePDFItem(path: oldPath, hash: hash, created: Date(timeIntervalSince1970: 10))) store.upsert(makePDFItem(path: newPath, hash: hash, created: Date(timeIntervalSince1970: 20))) store.flushPersistenceForTesting() cacheService.flushForTesting() XCTAssertEqual(store.items.count, 1) XCTAssertEqual(store.items.first?.payload, newPath) XCTAssertFalse(FileManager.default.fileExists(atPath: oldPath)) XCTAssertTrue(FileManager.default.fileExists(atPath: newPath)) } func testDuplicateReplacementClearsStaleImageSearchMetadata() throws { let settings = makeSettings(maxHistory: 50) settings.keepFirstImage = false let store = makeStore(settings: settings) let staleImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID())) let hash = "same-content" let imageItem = makeImageItem( fullPath: staleImage.full, thumbPath: staleImage.thumb, hash: hash, ocrText: "stale search marker", created: Date(timeIntervalSince1970: 10) ) let textItem = ClipboardItem( id: UUID(), kind: .text, displayText: "Replacement text", payload: "Replacement text", payloadHash: hash, createdAt: Date(timeIntervalSince1970: 20), lastUsedAt: Date(timeIntervalSince1970: 20), useCount: 1, sourceApp: "Notes", imagePath: nil, thumbnailPath: nil, isPinned: false, sourceAppBundleId: "com.apple.Notes", ocrText: nil ) store.upsert(imageItem) store.upsert(textItem) store.flushPersistenceForTesting() cacheService.flushForTesting() let item = try XCTUnwrap(store.items.first) XCTAssertEqual(store.items.count, 1) XCTAssertEqual(item.kind, .text) XCTAssertEqual(item.payload, textItem.payload) XCTAssertNil(item.imagePath) XCTAssertNil(item.thumbnailPath) XCTAssertNil(item.ocrText) XCTAssertFalse(item.searchableText.contains("stale search marker")) XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.full)) XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.thumb)) } private func makeSettings(maxHistory: Int) -> SettingsModel { let settings = SettingsModel(defaults: defaults) settings.maxHistoryItems = maxHistory settings.pruneDuplicates = true settings.keepFirstImage = true return settings } private func makeStore(settings: SettingsModel) -> ClipboardStore { makeStore(settings: settings, encryptionService: noOpEncryptionService()) } private func makeStore(settings: SettingsModel, encryptionService: ClipboardEncryptionService) -> ClipboardStore { ClipboardStore( settings: settings, cacheService: cacheService, baseURL: baseURL, encryptionService: encryptionService ) } private func makeItem(_ payload: String, displayText: String, created: Date) -> ClipboardItem { let hash = settingsHash(for: payload) return ClipboardItem( id: UUID(), kind: .text, displayText: displayText, payload: payload, payloadHash: hash, createdAt: created, lastUsedAt: created, useCount: 1, sourceApp: nil, imagePath: nil, thumbnailPath: nil, isPinned: false ) } private func makePDFItem(path: String, hash: String, created: Date) -> ClipboardItem { ClipboardItem( id: UUID(), kind: .pdf, displayText: "PDF", payload: path, payloadHash: hash, createdAt: created, lastUsedAt: created, useCount: 1, sourceApp: nil, imagePath: nil, thumbnailPath: nil, isPinned: false ) } private func makeImageItem(fullPath: String, thumbPath: String, hash: String, ocrText: String?, created: Date) -> ClipboardItem { ClipboardItem( id: UUID(), kind: .image, displayText: "Image", payload: fullPath, payloadHash: hash, createdAt: created, lastUsedAt: created, useCount: 1, sourceApp: "Preview", imagePath: fullPath, thumbnailPath: thumbPath, isPinned: false, sourceAppBundleId: "com.apple.Preview", ocrText: ocrText ) } private func makeImage(color: NSColor) -> NSImage { let size = NSSize(width: 24, height: 24) let image = NSImage(size: size) image.lockFocus() color.setFill() NSRect(origin: .zero, size: size).fill() image.unlockFocus() return image } private func settingsHash(for payload: String) -> String { payload } private func posixPermissions(_ url: URL) throws -> Int { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) return try XCTUnwrap(attributes[.posixPermissions] as? Int) & 0o777 } private func fileSize(_ url: URL) throws -> Int64 { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) let size = try XCTUnwrap(attributes[.size] as? NSNumber) return size.int64Value } private func databaseText() throws -> String { let data = try Data(contentsOf: baseURL.appendingPathComponent("history.sqlite")) return String(decoding: data, as: UTF8.self) } private func noOpEncryptionService() -> ClipboardEncryptionService { ClipboardEncryptionService(keyProvider: { nil }) } private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService { let keyData = Data(repeating: byte, count: 32) return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) }) } }