RadrootsAppleFileAccessTests.swift (10572B)
1 import Foundation 2 import Testing 3 @testable import RadrootsKit 4 5 @Test func appleFileAccessWritesReadsListsAndDeletesInlineFiles() throws { 6 let access = try testFileAccess() 7 let file = RadrootsFileReference(scope: .data, relativePath: "identity/public.json") 8 let data = Data(#"{"npub":"npub1test"}"#.utf8) 9 10 try access.write(.inline(data), to: file) 11 12 #expect(try access.read(file, mode: .inline) == .inline(data)) 13 let entries = try access.list(RadrootsFileReference(scope: .data, relativePath: "identity")) 14 #expect(entries.map(\.file.relativePath) == ["identity/public.json"]) 15 #expect(entries.first?.name == "public.json") 16 #expect(entries.first?.sizeBytes == data.count) 17 18 try access.delete(file) 19 try access.delete(file) 20 #expect(try access.list(RadrootsFileReference(scope: .data, relativePath: "identity")).isEmpty) 21 } 22 23 @Test func appleFileAccessWritesFilesFromStagedBlobsAndReleasesThem() throws { 24 let access = try testFileAccess() 25 let data = Data("hello staged blob".utf8) 26 let staged = try access.stageBlob(data, mediaType: "text/plain", filenameHint: "note.txt") 27 let file = RadrootsFileReference(scope: .cache, relativePath: "outbox/note.txt") 28 29 #expect(try access.readStagedBlob(staged) == data) 30 31 try access.write(.stagedBlob(staged), to: file) 32 try access.releaseStagedBlob(staged) 33 34 #expect(try access.read(file, mode: .inline) == .inline(data)) 35 #expect(throws: RadrootsAppleFileError.self) { 36 _ = try access.readStagedBlob(staged) 37 } 38 } 39 40 @Test func appleFileAccessStagesLargeReadsWhenInlineLimitIsExceeded() throws { 41 let access = try testFileAccess() 42 let file = RadrootsFileReference(scope: .data, relativePath: "events/large.json") 43 let data = Data("large payload".utf8) 44 45 try access.write(.inline(data), to: file) 46 47 guard case .stagedBlob(let staged) = try access.read(file, mode: .preferInline(maxBytes: 4)) else { 48 Issue.record("expected staged blob result") 49 return 50 } 51 52 #expect(staged.filenameHint == "large.json") 53 #expect(try access.readStagedBlob(staged) == data) 54 } 55 56 @Test func appleFileAccessStagesScopedFilesWithoutChangingTheSource() throws { 57 let access = try testFileAccess() 58 let file = RadrootsFileReference(scope: .data, relativePath: "events/large.json") 59 let data = Data(repeating: 7, count: 1_048_576) 60 61 try access.write(.inline(data), to: file) 62 let staged = try access.stageFile(file, mediaType: "application/json", filenameHint: "large.json") 63 64 try access.delete(file) 65 66 #expect(staged.mediaType == "application/json") 67 #expect(staged.filenameHint == "large.json") 68 #expect(staged.sizeBytes == data.count) 69 #expect(try access.readStagedBlob(staged) == data) 70 } 71 72 @Test func appleFileAccessStagesExternalFiles() throws { 73 let access = try testFileAccess() 74 let externalURL = try writeExternalTestFile(name: "selected.txt", data: Data("external".utf8)) 75 76 let staged = try access.stageExternalFile(externalURL, mediaType: "text/plain", filenameHint: nil) 77 78 #expect(staged.mediaType == "text/plain") 79 #expect(staged.filenameHint == "selected.txt") 80 #expect(try access.readStagedBlob(staged) == Data("external".utf8)) 81 } 82 83 @Test func appleFileAccessCopiesExternalFilesIntoScopedStorage() throws { 84 let access = try testFileAccess() 85 let externalURL = try writeExternalTestFile(name: "relays.json", data: Data(#"{"relays":[]}"#.utf8)) 86 let destination = RadrootsFileReference(scope: .data, relativePath: "imports/relays.json") 87 88 let imported = try access.copyExternalFile( 89 externalURL, 90 to: destination, 91 mediaType: "application/json", 92 suggestedFilename: nil 93 ) 94 95 #expect(imported.file == destination) 96 #expect(imported.originalURL == externalURL.standardizedFileURL) 97 #expect(imported.suggestedFilename == "relays.json") 98 #expect(imported.mediaType == "application/json") 99 #expect(imported.sizeBytes == UInt64(Data(#"{"relays":[]}"#.utf8).count)) 100 #expect(try access.read(destination, mode: .inline) == .inline(Data(#"{"relays":[]}"#.utf8))) 101 } 102 103 @Test func appleFileAccessPreparesAndReleasesExportDocuments() throws { 104 let access = try testFileAccess() 105 let file = RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json") 106 let data = Data(#"{"status":"ok"}"#.utf8) 107 try access.write(.inline(data), to: file) 108 let staged = try access.stageFile(file, mediaType: "application/json", filenameHint: "diagnostics.json") 109 110 let filePrepared = try access.prepareExport( 111 try RadrootsExportDocumentRequest( 112 source: .file(file), 113 suggestedFilename: "diagnostics.json", 114 mediaType: "application/json" 115 ) 116 ) 117 let stagedPrepared = try access.prepareExport( 118 try RadrootsExportDocumentRequest( 119 source: .stagedBlob(staged), 120 suggestedFilename: "staged-diagnostics.json", 121 mediaType: "application/json" 122 ) 123 ) 124 let inlinePrepared = try access.prepareExport( 125 try RadrootsExportDocumentRequest( 126 source: .inlineData(data), 127 suggestedFilename: "inline-diagnostics.json", 128 mediaType: "application/json" 129 ) 130 ) 131 132 #expect(try Data(contentsOf: filePrepared.fileURL) == data) 133 #expect(try Data(contentsOf: stagedPrepared.fileURL) == data) 134 #expect(try Data(contentsOf: inlinePrepared.fileURL) == data) 135 #expect(try access.preparedExportExists(filePrepared)) 136 #expect(try access.preparedExportExists(stagedPrepared)) 137 #expect(try access.preparedExportExists(inlinePrepared)) 138 #expect(filePrepared.sizeBytes == UInt64(data.count)) 139 #expect(stagedPrepared.sizeBytes == UInt64(data.count)) 140 #expect(inlinePrepared.sizeBytes == UInt64(data.count)) 141 142 try access.releasePreparedExport(filePrepared) 143 try access.releasePreparedExport(stagedPrepared) 144 try access.releasePreparedExport(inlinePrepared) 145 146 #expect(!(try access.preparedExportExists(filePrepared))) 147 #expect(!(try access.preparedExportExists(stagedPrepared))) 148 #expect(!(try access.preparedExportExists(inlinePrepared))) 149 } 150 151 @Test func appleFileAccessKeepsSmallReadsInlineWhenLimitAllowsIt() throws { 152 let access = try testFileAccess() 153 let file = RadrootsFileReference(scope: .logs, relativePath: "radroots.log") 154 let data = Data("log".utf8) 155 156 try access.write(.inline(data), to: file) 157 158 #expect(try access.read(file, mode: .preferInline(maxBytes: data.count)) == .inline(data)) 159 } 160 161 @Test func appleFileAccessRejectsInvalidStagedBlobMetadata() throws { 162 let access = try testFileAccess() 163 164 #expect(throws: RadrootsAppleFileError.self) { 165 _ = try RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1) 166 } 167 #expect(throws: RadrootsAppleFileError.self) { 168 _ = try access.stageBlob(Data("bad".utf8), mediaType: "text/plain", filenameHint: "../secret.txt") 169 } 170 #expect(throws: RadrootsAppleFileError.self) { 171 _ = try access.read(RadrootsFileReference(scope: .data, relativePath: "missing.json"), mode: .inline) 172 } 173 #expect(throws: RadrootsAppleFileError.self) { 174 _ = try access.stageExternalFile(URL(string: "https://radroots.org/file.json")!, mediaType: nil, filenameHint: nil) 175 } 176 #expect(throws: RadrootsAppleFileError.self) { 177 _ = try access.stageExternalFile(try writeExternalTestFile(name: "bad.txt", data: Data("bad".utf8)), mediaType: nil, filenameHint: "../bad.txt") 178 } 179 #expect(throws: RadrootsDocumentInterchangeError.self) { 180 _ = try access.prepareExport( 181 try RadrootsExportDocumentRequest( 182 source: .inlineData(Data("bad".utf8)), 183 suggestedFilename: "../bad.txt", 184 mediaType: "text/plain" 185 ) 186 ) 187 } 188 } 189 190 @Test func appleFileAccessSweepsOnlyExpiredStagedBlobs() throws { 191 let access = try testFileAccess() 192 let oldBlob = try access.stageBlob(Data("old".utf8), mediaType: nil, filenameHint: nil) 193 let newBlob = try access.stageBlob(Data("new".utf8), mediaType: nil, filenameHint: nil) 194 let oldURL = access.roots.stagedBlobsRoot.appendingPathComponent(oldBlob.blobID) 195 let oldDate = Date(timeIntervalSince1970: 10) 196 let cutoff = Date(timeIntervalSince1970: 20) 197 198 try FileManager.default.setAttributes([.modificationDate: oldDate], ofItemAtPath: oldURL.path) 199 200 let swept = try access.sweepStagedBlobs(olderThan: cutoff) 201 202 #expect(swept.map(\.blobID) == [oldBlob.blobID]) 203 #expect(throws: RadrootsAppleFileError.self) { 204 _ = try access.readStagedBlob(oldBlob) 205 } 206 #expect(try access.readStagedBlob(newBlob) == Data("new".utf8)) 207 } 208 209 @Test func appleFileAccessResetsFileRootsAndStagedBlobs() throws { 210 let access = try testFileAccess() 211 let dataFile = RadrootsFileReference(scope: .data, relativePath: "state.json") 212 let cacheFile = RadrootsFileReference(scope: .cache, relativePath: "cache.json") 213 let staged = try access.stageBlob(Data("blob".utf8), mediaType: nil, filenameHint: nil) 214 215 try access.write(.inline(Data("data".utf8)), to: dataFile) 216 try access.write(.inline(Data("cache".utf8)), to: cacheFile) 217 218 try access.reset(scope: .data) 219 220 #expect(try access.list(RadrootsFileReference(scope: .data, relativePath: "")).isEmpty) 221 #expect(try access.read(cacheFile, mode: .inline) == .inline(Data("cache".utf8))) 222 223 try access.resetStagedBlobs() 224 225 #expect(throws: RadrootsAppleFileError.self) { 226 _ = try access.readStagedBlob(staged) 227 } 228 } 229 230 private func testFileAccess() throws -> RadrootsAppleFileAccess { 231 let root = FileManager.default.temporaryDirectory 232 .appendingPathComponent("radroots-file-access-\(UUID().uuidString)", isDirectory: true) 233 let roots = try RadrootsAppleFileRoots( 234 appIdentifier: "org.radroots.tests", 235 dataRoot: root.appendingPathComponent("data", isDirectory: true), 236 cacheRoot: root.appendingPathComponent("cache", isDirectory: true), 237 temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) 238 ) 239 return RadrootsAppleFileAccess(roots: roots) 240 } 241 242 private func writeExternalTestFile(name: String, data: Data) throws -> URL { 243 let directory = FileManager.default.temporaryDirectory 244 .appendingPathComponent("radroots-file-access-external-\(UUID().uuidString)", isDirectory: true) 245 try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 246 let url = directory.appendingPathComponent(name) 247 try data.write(to: url) 248 return url.standardizedFileURL 249 }