179 lines
6.3 KiB
Swift
179 lines
6.3 KiB
Swift
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
|
|
))
|
|
)
|
|
}
|