Files
clipbored/tests/clipboredtests/ClipboardStoreTests.swift
Akshay Kolli d86d392c3a
Some checks failed
CI / Test And Build (push) Has been cancelled
UI fixes
2026-07-08 01:13:02 -04:00

970 lines
40 KiB
Swift

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 testSetCustomTitlePersistsAcrossReloadAndClears() {
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.setCustomTitle(itemID, title: " Launch Brief ")
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.customTitle, "Launch Brief")
XCTAssertEqual(restored.items.first?.payload, "alpha")
let restoredID = try! XCTUnwrap(restored.items.first?.id)
restored.setCustomTitle(restoredID, title: " ")
restored.flushPersistenceForTesting()
let cleared = makeStore(settings: settings)
cleared.flushPersistenceForTesting()
XCTAssertNil(cleared.items.first?.customTitle)
XCTAssertEqual(cleared.items.first?.payload, "alpha")
}
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)
XCTAssertEqual(store.items.first?.sourceDeviceName, ClipboardItem.localDeviceName)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.payload, "legacy payload")
XCTAssertEqual(restored.items.first?.sourceDeviceName, ClipboardItem.localDeviceName)
}
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 testCollectionItemsSurviveNormalHistoryPrune() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let start = Date()
var saved = makeItem("collection-old", displayText: "Saved", created: start.addingTimeInterval(-500))
saved.collectionName = "Client Work"
store.upsert(saved)
for index in 0..<60 {
store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index))))
}
store.flushPersistenceForTesting()
XCTAssertTrue(store.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" }))
XCTAssertEqual(store.items.filter { !$0.isPinned && $0.collectionName == nil }.count, 50)
XCTAssertEqual(store.items.count, 51)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertTrue(restored.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" }))
}
func testRemovingCollectionMakesOverflowItemEligibleForHistoryPrune() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let start = Date()
var saved = makeItem("collection-old", displayText: "Saved", created: start.addingTimeInterval(-500))
saved.collectionName = "Client Work"
store.upsert(saved)
for index in 0..<60 {
store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index))))
}
let savedID = try! XCTUnwrap(store.items.first(where: { $0.payload == "collection-old" })?.id)
store.setCollection(savedID, name: nil)
store.flushPersistenceForTesting()
XCTAssertFalse(store.items.contains(where: { $0.payload == "collection-old" }))
XCTAssertEqual(store.items.count, 50)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertFalse(restored.items.contains(where: { $0.payload == "collection-old" }))
}
func testHistoryRetentionPrunesExpiredUnpinnedItems() {
let settings = makeSettings(maxHistory: 50)
settings.historyRetention = .oneDay
let store = makeStore(settings: settings)
let now = Date()
store.upsert(makeItem("old", displayText: "Old", created: now.addingTimeInterval(-2 * 24 * 60 * 60)))
store.upsert(makeItem("recent", displayText: "Recent", created: now.addingTimeInterval(-60)))
store.flushPersistenceForTesting()
XCTAssertEqual(store.items.map(\.payload), ["recent"])
XCTAssertFalse(store.items.contains(where: { $0.payload == "old" }))
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.map(\.payload), ["recent"])
}
func testHistoryRetentionKeepsPinnedAndCollectionItems() {
let settings = makeSettings(maxHistory: 50)
settings.historyRetention = .oneDay
let store = makeStore(settings: settings)
let oldDate = Date().addingTimeInterval(-2 * 24 * 60 * 60)
var pinned = makeItem("pinned-old", displayText: "Pinned", created: oldDate)
pinned.isPinned = true
var saved = makeItem("collection-old", displayText: "Saved", created: oldDate)
saved.collectionName = "Client Work"
store.upsert(makeItem("plain-old", displayText: "Plain", created: oldDate))
store.upsert(pinned)
store.upsert(saved)
store.flushPersistenceForTesting()
XCTAssertFalse(store.items.contains(where: { $0.payload == "plain-old" }))
XCTAssertTrue(store.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
XCTAssertTrue(store.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" }))
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertFalse(restored.items.contains(where: { $0.payload == "plain-old" }))
XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
XCTAssertTrue(restored.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" }))
}
func testDuplicateCopyRefreshesRetentionAge() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let oldDate = Date().addingTimeInterval(-2 * 24 * 60 * 60)
store.upsert(makeItem("same", displayText: "Original", created: oldDate))
let originalCreatedAt = try! XCTUnwrap(store.items.first?.createdAt)
store.upsert(makeItem("same", displayText: "Copied again", created: oldDate))
XCTAssertGreaterThan(try! XCTUnwrap(store.items.first?.createdAt), originalCreatedAt)
settings.historyRetention = .oneDay
store.normalizeHistoryLength()
XCTAssertEqual(store.items.map(\.payload), ["same"])
XCTAssertEqual(store.items.first?.displayText, "Copied again")
}
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)",
sourceDeviceName: "Device 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!))
XCTAssertFalse(rawDatabaseText.contains(item.sourceDeviceName!))
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)
XCTAssertEqual(restored.items.first?.sourceDeviceName, item.sourceDeviceName)
}
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)",
sourceDeviceName: "Legacy device \(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)
XCTAssertEqual(restored.items.first?.sourceDeviceName, item.sourceDeviceName)
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!))
XCTAssertFalse(migratedDatabaseText.contains(item.sourceDeviceName!))
}
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 testRemoveCanDeferManagedCachePurgeForUndoRestore() throws {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let cachedImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
let item = makeImageItem(
fullPath: cachedImage.full,
thumbPath: cachedImage.thumb,
hash: "undo-image",
ocrText: "undo searchable text",
created: Date(timeIntervalSince1970: 10)
)
store.upsert(item)
store.flushPersistenceForTesting()
let removal = try XCTUnwrap(store.remove(item.id, purgeManagedCache: false))
store.flushPersistenceForTesting()
cacheService.flushForTesting()
XCTAssertTrue(store.items.isEmpty)
XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.full))
XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.thumb))
store.restore([removal])
store.flushPersistenceForTesting()
XCTAssertEqual(store.items.map(\.id), [item.id])
XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.full))
XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.thumb))
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.map(\.id), [item.id])
_ = store.remove(item.id)
store.flushPersistenceForTesting()
cacheService.flushForTesting()
XCTAssertFalse(FileManager.default.fileExists(atPath: cachedImage.full))
XCTAssertFalse(FileManager.default.fileExists(atPath: cachedImage.thumb))
}
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))
}
func testArchiveExportImportMovesHistoryAndManagedAttachmentsToNewStorage() throws {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let cachedImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
let sourceFullImageData = try XCTUnwrap(cacheService.data(for: cachedImage.full))
let sourceThumbData = try XCTUnwrap(cacheService.data(for: cachedImage.thumb))
let sourcePDFData = Data("%PDF archive payload".utf8)
let sourcePDFPath = try XCTUnwrap(cacheService.cachePDF(sourcePDFData, id: UUID()))
let created = Date(timeIntervalSince1970: 300)
var note = makeItem("archive note", displayText: "Archive Note", created: created)
note.collectionName = "Read Later"
note.customTitle = "Migration Note"
note.sourceDeviceName = "Studio Mac"
var image = makeImageItem(
fullPath: cachedImage.full,
thumbPath: cachedImage.thumb,
hash: "archive-image",
ocrText: "blue archive image",
created: created.addingTimeInterval(1)
)
image.isPinned = true
image.collectionName = "Client Work"
image.customTitle = "Launch Screenshot"
image.sourceDeviceName = "MacBook Pro"
var pdf = makePDFItem(
path: sourcePDFPath,
hash: "archive-pdf",
created: created.addingTimeInterval(2)
)
pdf.collectionName = "Research"
pdf.sourceDeviceName = "Design Mac"
store.upsert(note)
store.upsert(image)
store.upsert(pdf)
store.flushPersistenceForTesting()
let archiveURL = baseURL.appendingPathComponent("backup.clipboredarchive")
let exportSummary = try store.exportArchive(to: archiveURL)
XCTAssertEqual(exportSummary.itemCount, 3)
XCTAssertEqual(exportSummary.sidecarCount, 3)
XCTAssertEqual(try posixPermissions(archiveURL), 0o600)
let destinationBaseURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: destinationBaseURL, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: destinationBaseURL) }
let destinationDefaultsSuite = "com.clipbored.archiveimport.\(UUID().uuidString)"
let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite))
destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite)
defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) }
let destinationSettings = SettingsModel(defaults: destinationDefaults)
destinationSettings.maxHistoryItems = 50
destinationSettings.historyRetention = .forever
let destinationCache = ClipboardCacheService(
baseURL: destinationBaseURL,
encryptionService: noOpEncryptionService()
)
let destinationStore = ClipboardStore(
settings: destinationSettings,
cacheService: destinationCache,
baseURL: destinationBaseURL,
encryptionService: noOpEncryptionService()
)
destinationStore.upsert(makeItem("destination only", displayText: "Destination", created: created.addingTimeInterval(3)))
destinationStore.flushPersistenceForTesting()
let importSummary = try destinationStore.importArchive(from: archiveURL)
destinationStore.flushPersistenceForTesting()
XCTAssertEqual(importSummary.itemCount, 3)
XCTAssertEqual(importSummary.sidecarCount, 3)
XCTAssertEqual(destinationStore.items.count, 4)
XCTAssertTrue(destinationStore.items.contains { $0.payload == "destination only" })
let importedNote = try XCTUnwrap(destinationStore.items.first { $0.id == note.id })
XCTAssertEqual(importedNote.collectionName, "Read Later")
XCTAssertEqual(importedNote.customTitle, "Migration Note")
XCTAssertEqual(importedNote.sourceDeviceName, "Studio Mac")
let importedImage = try XCTUnwrap(destinationStore.items.first { $0.id == image.id })
XCTAssertTrue(importedImage.isPinned)
XCTAssertEqual(importedImage.collectionName, "Client Work")
XCTAssertEqual(importedImage.customTitle, "Launch Screenshot")
XCTAssertEqual(importedImage.sourceDeviceName, "MacBook Pro")
let importedImagePath = try XCTUnwrap(importedImage.imagePath)
let importedThumbPath = try XCTUnwrap(importedImage.thumbnailPath)
XCTAssertTrue(importedImagePath.hasPrefix(destinationBaseURL.path))
XCTAssertTrue(importedThumbPath.hasPrefix(destinationBaseURL.path))
XCTAssertNotEqual(importedImagePath, cachedImage.full)
XCTAssertEqual(destinationCache.data(for: importedImagePath), sourceFullImageData)
XCTAssertEqual(destinationCache.data(for: importedThumbPath), sourceThumbData)
let importedPDF = try XCTUnwrap(destinationStore.items.first { $0.id == pdf.id })
XCTAssertEqual(importedPDF.collectionName, "Research")
XCTAssertTrue(importedPDF.payload.hasPrefix(destinationBaseURL.path))
XCTAssertNotEqual(importedPDF.payload, sourcePDFPath)
XCTAssertEqual(destinationCache.data(for: importedPDF.payload), sourcePDFData)
let reloaded = ClipboardStore(
settings: destinationSettings,
cacheService: destinationCache,
baseURL: destinationBaseURL,
encryptionService: noOpEncryptionService()
)
reloaded.flushPersistenceForTesting()
XCTAssertEqual(reloaded.items.count, 4)
XCTAssertTrue(reloaded.items.contains { $0.id == image.id && $0.collectionName == "Client Work" })
XCTAssertTrue(reloaded.items.contains { $0.id == pdf.id && $0.collectionName == "Research" })
}
func testPinboardExportImportsOnlyThatCollectionAndPreservesColor() throws {
let settings = makeSettings(maxHistory: 50)
settings.ensureCollection(named: "Client Work", colorHex: "#3366FF")
let store = makeStore(settings: settings)
let created = Date(timeIntervalSince1970: 500)
var client = makeItem("client note", displayText: "Client Note", created: created)
client.collectionName = "Client Work"
let unrelated = makeItem("other note", displayText: "Other Note", created: created.addingTimeInterval(1))
store.upsert(client)
store.upsert(unrelated)
store.flushPersistenceForTesting()
let archiveURL = baseURL.appendingPathComponent("client-work.clipboredarchive")
let exportSummary = try store.exportCollection(named: "Client Work", to: archiveURL)
XCTAssertEqual(exportSummary.itemCount, 1)
XCTAssertEqual(exportSummary.sidecarCount, 0)
let destinationDefaultsSuite = "com.clipbored.pinboardimport.\(UUID().uuidString)"
let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite))
destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite)
defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) }
let destinationSettings = SettingsModel(defaults: destinationDefaults)
destinationSettings.maxHistoryItems = 50
destinationSettings.historyRetention = .forever
let destinationBaseURL = baseURL.appendingPathComponent("pinboard-destination", isDirectory: true)
let destinationCache = ClipboardCacheService(
baseURL: destinationBaseURL,
encryptionService: noOpEncryptionService()
)
let destinationStore = ClipboardStore(
settings: destinationSettings,
cacheService: destinationCache,
baseURL: destinationBaseURL,
encryptionService: noOpEncryptionService()
)
let importSummary = try destinationStore.importArchive(from: archiveURL)
XCTAssertEqual(importSummary.itemCount, 1)
XCTAssertEqual(destinationStore.items.map(\.payload), ["client note"])
XCTAssertEqual(destinationStore.items.first?.collectionName, "Client Work")
XCTAssertEqual(destinationSettings.customCollectionNames, ["Client Work"])
XCTAssertEqual(destinationSettings.collectionColorHex(forCollectionNamed: "Client Work"), "#3366FF")
}
func testEmptyPinboardExportImportsCollectionMetadata() throws {
let settings = makeSettings(maxHistory: 50)
settings.ensureCollection(named: "Read Later", colorHex: "#0A9EB8")
let store = makeStore(settings: settings)
let archiveURL = baseURL.appendingPathComponent("read-later-empty.clipboredarchive")
let exportSummary = try store.exportCollection(named: "Read Later", to: archiveURL)
XCTAssertEqual(exportSummary.itemCount, 0)
let destinationDefaultsSuite = "com.clipbored.emptypinboard.\(UUID().uuidString)"
let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite))
destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite)
defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) }
let destinationSettings = SettingsModel(defaults: destinationDefaults)
let destinationBaseURL = baseURL.appendingPathComponent("empty-pinboard-destination", isDirectory: true)
let destinationStore = ClipboardStore(
settings: destinationSettings,
cacheService: ClipboardCacheService(baseURL: destinationBaseURL, encryptionService: noOpEncryptionService()),
baseURL: destinationBaseURL,
encryptionService: noOpEncryptionService()
)
let importSummary = try destinationStore.importArchive(from: archiveURL)
XCTAssertEqual(importSummary.itemCount, 0)
XCTAssertTrue(destinationStore.items.isEmpty)
XCTAssertEqual(destinationSettings.customCollectionNames, ["Read Later"])
XCTAssertEqual(destinationSettings.collectionColorHex(forCollectionNamed: "Read Later"), "#0A9EB8")
}
private func makeSettings(maxHistory: Int) -> SettingsModel {
let settings = SettingsModel(defaults: defaults)
settings.maxHistoryItems = maxHistory
settings.historyRetention = .forever
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) })
}
}