From 70231574c991f91281a429ffe132f1ec1b54d902 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 09:28:17 +0400 Subject: [PATCH] test: launch-paths BLOCKER regression + isWritableDirectory unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReturnWatcherTests.swift: two new tests bracket the BLOCKER fix — one characterizes the old bug (FolderSettings.resolvedAppSupportPaths(), AirDrop- only, ignores the persisted OneDrive transport; the watcher ends up watching a stale isolated folder and misses a marked PDF dropped into the real OneDrive-mode folder), the other proves the fix (the exact launch/bootstrap construction — TransportSettings.resolvedAppSupportPaths() + watcher.updateWatchFolder() before start — detects it). Both isolated to temp dirs, including an explicit FolderSettings watch-folder override so the "bug" test's found.isEmpty assertion never depends on what's actually in Ben's real ~/Downloads (it does, in fact, already contain real marked-up Redline PDFs from prior testing — an earlier version of this test read the REAL Downloads folder and failed for exactly that reason). TransportSettingsTests.swift: three tests for OneDriveLocator.isWritableDirectory — true for an ordinary directory, false for one chmod'd 500 (permissions restored in teardown before removal), false for a plain file and for a nonexistent path. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp --- .../ReturnWatcherTests.swift | 107 ++++++++++++++++++ .../TransportSettingsTests.swift | 32 ++++++ 2 files changed, 139 insertions(+) diff --git a/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift index 3278649..679b504 100644 --- a/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift +++ b/Tests/ShotdeckCoreTests/ReturnWatcherTests.swift @@ -282,3 +282,110 @@ func recordUncommentedFalseSkipsUnmarkedThenRecordsAfterInPlaceMarkup() async th #expect(commented.count == 1) #expect(commented.first?.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path) } + +// MARK: - Launch-paths BLOCKER regression (adversarial review, 20260905) +// +// The bug: AppDelegate.makeLaunchModel() built `paths` via the AirDrop-only +// FolderSettings.resolvedAppSupportPaths(), so ReturnWatcher's internal watchFolder +// (seeded from paths.watchFolder in its own init) was the AirDrop folder even when +// OneDrive was the persisted transport, and bootstrap() never reconciled it before +// starting. Net effect: PDFs went to OneDrive but FSEvents kept watching the stale +// AirDrop folder for the whole session — marked-up returns were never detected. +// The fix: launch paths now come from TransportSettings.resolvedAppSupportPaths() +// (transport-aware), and AppModel.bootstrap() unconditionally reconciles the watcher's +// folder via updateWatchFolder() before it starts. These two tests characterize the +// bug (still reproducible via the old AirDrop-only construction) and prove the fix +// (the real launch-construction path, end to end). + +@Test("Launch regression (fix): OneDrive persisted -> transport-aware launch paths -> bootstrap-style reconcile -> a marked PDF is detected") +func launchStyleConstructionWithOneDriveTransportDetectsAMarkedReturn() async throws { + let suite = try makeTransportDefaultsSuite() + defer { tearDownTransportSuite(suite) } + let oneDriveFolder = try makeTransportTemporaryDirectory(prefix: "shotdeck-launch-onedrive") + defer { try? FileManager.default.removeItem(at: oneDriveFolder) } + let appSupportRoot = try makeTransportTemporaryDirectory(prefix: "shotdeck-launch-approot") + defer { try? FileManager.default.removeItem(at: appSupportRoot) } + + TransportSettings.setTransport(.oneDrive, defaults: suite.defaults) + TransportSettings.setOneDriveFolder(oneDriveFolder, defaults: suite.defaults) + + // Exactly what AppDelegate.makeLaunchModel() now does: build launch paths from the + // transport-aware resolver — the fix, NOT FolderSettings.resolvedAppSupportPaths(), + // which is AirDrop-only and is the root cause the next test characterizes. + let paths = try TransportSettings.resolvedAppSupportPaths( + root: appSupportRoot, defaults: suite.defaults, fileManager: .default + ) + #expect(paths.outbox.path == oneDriveFolder.path) + #expect(paths.watchFolder.path == oneDriveFolder.path) + + let ledger = try ReturnLedger(paths: paths) + let watcher = ReturnWatcher(paths: paths, ledger: ledger) + + // What AppModel.bootstrap() now does, unconditionally, before watcher.start(): + await watcher.setRecordUncommented(false) // transport == .oneDrive + try await watcher.updateWatchFolder(paths.watchFolder) + + let pdfURL = oneDriveFolder.appendingPathComponent("Redline-20260905-100000.pdf") + try makePDF( + at: pdfURL, pageCount: 1, creator: "Redline", + annotations: [(page: 0, annotation: makeAnnotation( + .ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50) + ))] + ) + + let found = try await watcher.scanNow() + #expect(found.contains(where: { + $0.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path && $0.isCommented + })) + + let commented = try await ledger.commented() + #expect(commented.contains(where: { + $0.fileURL.resolvingSymlinksInPath().path == pdfURL.resolvingSymlinksInPath().path + })) +} + +@Test("Launch regression (characterizes the bug): AirDrop-only launch paths with no reconcile miss an OneDrive-mode return") +func airDropOnlyLaunchPathsWithoutReconcileMissesAMarkedOneDriveReturn() async throws { + let suite = try makeTransportDefaultsSuite() + defer { tearDownTransportSuite(suite) } + let oneDriveFolder = try makeTransportTemporaryDirectory(prefix: "shotdeck-buggy-onedrive") + defer { try? FileManager.default.removeItem(at: oneDriveFolder) } + // A configured AirDrop watch-folder override, isolated to a temp dir — NOT the real + // ~/Downloads, which may already hold real marked-up Redline PDFs from actual use + // and would make this test's "found.isEmpty" assertion depend on the state of + // Ben's real Downloads folder instead of the isolated fixture under test. + let staleAirDropFolder = try makeTransportTemporaryDirectory(prefix: "shotdeck-buggy-airdrop-stale") + defer { try? FileManager.default.removeItem(at: staleAirDropFolder) } + let appSupportRoot = try makeTransportTemporaryDirectory(prefix: "shotdeck-buggy-approot") + defer { try? FileManager.default.removeItem(at: appSupportRoot) } + + TransportSettings.setTransport(.oneDrive, defaults: suite.defaults) + TransportSettings.setOneDriveFolder(oneDriveFolder, defaults: suite.defaults) + FolderSettings.setWatchFolder(staleAirDropFolder, defaults: suite.defaults) + + // The BUG's exact construction: FolderSettings.resolvedAppSupportPaths() ignores + // the persisted transport entirely and always resolves the AirDrop folders. + let buggyPaths = try FolderSettings.resolvedAppSupportPaths(root: appSupportRoot, defaults: suite.defaults) + #expect(buggyPaths.watchFolder.path == staleAirDropFolder.path) + #expect(buggyPaths.watchFolder.path != oneDriveFolder.path) + + let ledger = try ReturnLedger(paths: buggyPaths) + let watcher = ReturnWatcher(paths: buggyPaths, ledger: ledger) + // The old bootstrap(): recordUncommented was set, but there was NO + // updateWatchFolder() call before start() to reconcile the folder. + await watcher.setRecordUncommented(false) + + let pdfURL = oneDriveFolder.appendingPathComponent("Redline-20260905-100100.pdf") + try makePDF( + at: pdfURL, pageCount: 1, creator: "Redline", + annotations: [(page: 0, annotation: makeAnnotation( + .ink, bounds: CGRect(x: 100, y: 100, width: 120, height: 50) + ))] + ) + + // The watcher is still pointed at the stale (configured-AirDrop) watch folder, so + // scanning it — NOT the OneDrive folder the PDF actually landed in — finds nothing. + // This is the exact BLOCKER the fix above closes. + let found = try await watcher.scanNow() + #expect(found.isEmpty) +} diff --git a/Tests/ShotdeckCoreTests/TransportSettingsTests.swift b/Tests/ShotdeckCoreTests/TransportSettingsTests.swift index 5de12d2..8b8991f 100644 --- a/Tests/ShotdeckCoreTests/TransportSettingsTests.swift +++ b/Tests/ShotdeckCoreTests/TransportSettingsTests.swift @@ -90,6 +90,38 @@ func oneDriveFolderUnavailableErrorDescriptionContainsThePath() throws { #expect(description.contains(path)) } +@Test +func isWritableDirectoryTrueForAnOrdinaryWritableDirectory() throws { + let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-writable") + defer { try? FileManager.default.removeItem(at: dir) } + #expect(OneDriveLocator.isWritableDirectory(at: dir)) +} + +@Test +func isWritableDirectoryFalseForAnExistingButUnwritableDirectory() throws { + let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-unwritable") + defer { + // Restore perms BEFORE removal — an unwritable dir can't otherwise be cleaned up. + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dir.path) + try? FileManager.default.removeItem(at: dir) + } + #expect(OneDriveLocator.isWritableDirectory(at: dir)) // sanity check before chmod + + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: dir.path) + #expect(!OneDriveLocator.isWritableDirectory(at: dir)) +} + +@Test +func isWritableDirectoryFalseForAPlainFileAndForANonexistentPath() throws { + let dir = try makeTransportTemporaryDirectory(prefix: "shotdeck-writable-check-parent") + defer { try? FileManager.default.removeItem(at: dir) } + let filePath = dir.appendingPathComponent("plain-file.txt") + FileManager.default.createFile(atPath: filePath.path, contents: Data("x".utf8)) + + #expect(!OneDriveLocator.isWritableDirectory(at: filePath)) + #expect(!OneDriveLocator.isWritableDirectory(at: dir.appendingPathComponent("does-not-exist"))) +} + struct TransportDefaultsSuite { let name: String let defaults: UserDefaults