import Foundation import IndieSync import Testing @testable import Notes struct NoteDocumentTests { private func makeNote() -> Note { var context = CaptureContext.empty context.latitude = 37.8044 context.longitude = -122.2712 context.placeName = "Blue Bottle Coffee" context.locality = "Oakland" context.timeZoneID = "America/Los_Angeles" context.device = "iPhone" return Note.create(text: "Barista's name is Sam\nLikes cortados", tags: ["coffee", "people"], context: context) } @Test func codableRoundTrip() throws { let note = makeNote() let data = try DocumentCoder.encode(note) let decoded = try DocumentCoder.decode(Note.self, from: data) #expect(decoded.payload == note.payload) #expect(decoded.meta.id == note.meta.id) #expect(decoded.meta.schemaVersion == note.meta.schemaVersion) // ISO-8601 encoding truncates sub-second precision. #expect(abs(decoded.meta.createdAt.timeIntervalSince(note.meta.createdAt)) < 1) #expect(abs(decoded.meta.modifiedAt.timeIntervalSince(note.meta.modifiedAt)) < 1) } @Test func forwardGateQuarantinesNewerSchema() { var note = makeNote() note.meta.schemaVersion = Note.currentSchemaVersion + 1 #expect(!note.isReadable) #expect(makeNote().isReadable) } @Test func relativePathIsUTCBucketed() { let note = makeNote() let path = note.relativePath #expect(path.hasSuffix("\(note.meta.id.stringValue).json")) #expect(path == TimeBucketedLayout.relativePath(for: note.meta.id)) } @Test func titleIsFirstLine() { #expect(makeNote().title == "Barista's name is Sam") } // MARK: - Schema v2 (audio) /// A v1 file predates the `audio` key. It must decode unchanged — as a text /// note with `audio == nil` — and pass the forward gate (1 ≤ current). @Test func v1DocumentDecodesAsTextNoteWithNoAudio() throws { let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 1, randomLo: 2) let json = """ { "meta": { "id": "\(id.stringValue)", "schemaVersion": 1, "createdAt": "2023-11-14T22:13:20Z", "modifiedAt": "2023-11-14T22:13:20Z" }, "payload": { "text": "A v1 note", "source": "typed", "tags": ["legacy"], "context": { "device": "iPhone", "timeZoneID": "UTC" } } } """ let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8)) #expect(note.meta.schemaVersion == 1) #expect(note.payload.audio == nil) #expect(note.payload.text == "A v1 note") #expect(note.isReadable) } /// A file stamped with a newer schema (and carrying a field this build /// doesn't model) must be quarantined by the gate — Codable silently drops /// the unknown key, so partial-decoding then rewriting would downgrade it. @Test func forwardGateQuarantinesV3Fixture() throws { let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 3, randomLo: 4) let json = """ { "meta": { "id": "\(id.stringValue)", "schemaVersion": 3, "createdAt": "2023-11-14T22:13:20Z", "modifiedAt": "2023-11-14T22:13:20Z" }, "payload": { "text": "From the future", "source": "typed", "tags": [], "context": {}, "audio": null, "unknownFutureField": { "whatever": true } } } """ let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8)) #expect(note.meta.schemaVersion == 3) #expect(!note.isReadable) } @Test func audioNoteCodableRoundTrip() throws { var context = CaptureContext.empty context.device = "Watch" context.timeZoneID = "Europe/Berlin" let audio = Note.AudioInfo( duration: 12.5, transcriberDeviceID: "install-abc", transcriptionStatus: .done, transcriptionLocaleID: "ru_RU", transcribedAt: Date(timeIntervalSince1970: 1_700_000_100) ) let note = Note.create(text: "привет", source: .transcribed, tags: ["voice"], context: context, audio: audio) let data = try DocumentCoder.encode(note) let decoded = try DocumentCoder.decode(Note.self, from: data) #expect(decoded.payload == note.payload) #expect(decoded.payload.audio?.transcriptionStatus == .done) #expect(decoded.payload.audio?.transcriptionLocaleID == "ru_RU") } @Test func audioPathDerivationSwapsExtension() { let id = ULID() let jsonPath = Note.relativePath(forID: id) #expect(jsonPath.hasSuffix(".json")) #expect(Note.audioRelativePath(forID: id) == jsonPath.replacingOccurrences(of: ".json", with: ".m4a")) #expect(Note.audioRelativePath(forID: id).hasSuffix("\(id.stringValue).m4a")) } /// The swap must operate on the note's actual path, so a record filed under /// a pre-fix local-time-zone month keeps its sidecar in the same bucket. @Test func audioPathPreservesLegacyBucket() { let id = ULID() let legacyJSONPath = "1999/12/\(id.stringValue).json" #expect(Note.audioRelativePath(forJSONPath: legacyJSONPath) == "1999/12/\(id.stringValue).m4a") } @Test func createIngestedDerivesCreatedAtFromULID() { let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 5, randomLo: 6) var context = CaptureContext.empty context.device = "Watch" let note = Note.createIngested(id: id, duration: 8, transcriberDeviceID: "install-x", context: context) #expect(note.meta.id == id) #expect(note.meta.createdAt == id.timestamp) #expect(note.meta.modifiedAt == id.timestamp) #expect(note.payload.text.isEmpty) #expect(note.payload.source == .transcribed) #expect(note.payload.audio?.transcriptionStatus == .pending) #expect(note.payload.audio?.transcriberDeviceID == "install-x") #expect(note.relativePath == Note.relativePath(forID: id)) } }