import Foundation import Testing import ShotdeckCore @Test func atomicWriteProducesByteIdenticalFileAndLeavesNoTmp() throws { let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-write") defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("payload.bin") let data = Data("shotdeck-durable-bytes".utf8) try AtomicFile.write(data, to: url) let onDisk = try Data(contentsOf: url) #expect(onDisk == data) #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) } @Test func atomicWriteToTheSameURLTwiceKeepsTheSecondPayload() throws { let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-rewrite") defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("payload.bin") let first = Data("first-pass".utf8) let second = Data("second-pass-wins".utf8) try AtomicFile.write(first, to: url) try AtomicFile.write(second, to: url) let onDisk = try Data(contentsOf: url) #expect(onDisk == second) #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) } @Test func atomicWriteThrowsWhenParentDirectoryDoesNotExist() throws { let missingParent = FileManager.default.temporaryDirectory .appendingPathComponent("shotdeck-atomic-missing-\(UUID().uuidString)", isDirectory: true) let url = missingParent.appendingPathComponent("payload.bin") let data = Data("never-written".utf8) let error = try #require(throws: ShotdeckError.self) { try AtomicFile.write(data, to: url) } guard case .spoolWriteFailed(let path, _) = error else { Issue.record("expected spoolWriteFailed, got \(error)") return } #expect(path == url.path) #expect(!FileManager.default.fileExists(atPath: url.path)) } @Test func atomicWriteClearsAStaleTmpAndWritesTheNewPayload() throws { let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-stale-tmp") defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("payload.bin") let tmpURL = directory.appendingPathComponent(url.lastPathComponent + ".tmp") let garbage = Data("stale-crash-garbage".utf8) let newData = Data("recovered-payload".utf8) try garbage.write(to: tmpURL) #expect(FileManager.default.fileExists(atPath: tmpURL.path)) try AtomicFile.write(newData, to: url) let onDisk = try Data(contentsOf: url) #expect(onDisk == newData) #expect(!FileManager.default.fileExists(atPath: tmpURL.path)) } @Test func writeJSONSortsKeysPrettyPrintsAndEncodesDatesAsISO8601() throws { let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-json-format") defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("session.json") let date = try iso8601Date(year: 2026, month: 8, day: 30, hour: 9, minute: 42, second: 5) let value = JSONProbe(zebra: "last", apple: 7, capturedAt: date) try AtomicFile.writeJSON(value, to: url) let raw = try Data(contentsOf: url) let text = try #require(String(data: raw, encoding: .utf8)) let apple = try #require(text.range(of: "\"apple\"")) let capturedAt = try #require(text.range(of: "\"capturedAt\"")) let zebra = try #require(text.range(of: "\"zebra\"")) #expect(apple.lowerBound < capturedAt.lowerBound) #expect(capturedAt.lowerBound < zebra.lowerBound) #expect(text.contains("\n")) #expect(text.contains(" \"apple\"")) #expect(text.contains("2026-08-30T09:42:05Z")) #expect(!text.contains("\(date.timeIntervalSinceReferenceDate)")) #expect(!FileManager.default.fileExists(atPath: url.path + ".tmp")) } @Test func writeJSONRoundTripsThroughISO8601Decoder() throws { let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-json-roundtrip") defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("session.json") let date = try iso8601Date(year: 2026, month: 8, day: 31, hour: 13, minute: 5, second: 9) let original = JSONProbe(zebra: "keep", apple: 42, capturedAt: date) try AtomicFile.writeJSON(original, to: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let decoded = try decoder.decode(JSONProbe.self, from: Data(contentsOf: url)) #expect(decoded == original) } @Test func fsyncDirectorySucceedsOnADirectoryAndThrowsOnMissingOrFilePaths() throws { let directory = try makeTemporaryDirectory(prefix: "shotdeck-atomic-fsync-dir") defer { try? FileManager.default.removeItem(at: directory) } try AtomicFile.fsyncDirectory(at: directory) let missing = directory.appendingPathComponent("does-not-exist", isDirectory: true) let missingError = try #require(throws: ShotdeckError.self) { try AtomicFile.fsyncDirectory(at: missing) } guard case .spoolWriteFailed = missingError else { Issue.record("expected spoolWriteFailed for a missing path, got \(missingError)") return } let fileURL = directory.appendingPathComponent("not-a-directory.bin") try AtomicFile.write(Data("file".utf8), to: fileURL) let fileError = try #require(throws: ShotdeckError.self) { try AtomicFile.fsyncDirectory(at: fileURL) } guard case .spoolWriteFailed = fileError else { Issue.record("expected spoolWriteFailed for a file path, got \(fileError)") return } } private struct JSONProbe: Codable, Equatable { var zebra: String var apple: Int var capturedAt: Date } private func makeTemporaryDirectory(prefix: String) throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } private func iso8601Date( year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int ) throws -> Date { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) return try #require( calendar.date(from: DateComponents( year: year, month: month, day: day, hour: hour, minute: minute, second: second )) ) }