Files
Luma/Tests/ArchiveStoreTests.swift
wt 2884810ec4
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
finish SwiftData migration with @Query views
- Drive chat list, timeline and forward picker from SwiftData @Query
  instead of AppModel's in-memory arrays; inject the per-account
  ModelContext from RootView with an in-memory fallback.
- Physically delete locally deleted messages (context.delete) and
  purge rows marked deleted by older builds on store open, so @Query
  views never resurrect them.
- Delete the legacy JSON snapshot only after a successful import save
  and restore the completeUntilFirstUserAuthentication data protection
  attribute on the store file.
- Mirror the old ChatArchive tolerant decoding in LegacyArchiveImporter
  so snapshots from older schema versions (missing reactions,
  isGroupMessage, roster fields) still import.
- Cover the new behaviour in ArchiveStoreTests and guard it with new
  verify.sh invariants; update AGENTS.md, ARCHITECTURE.md and
  SECURITY.md.
2026-08-29 00:57:08 +07:00

204 lines
8.3 KiB
Swift

import SwiftData
import XCTest
@testable import Luma
@MainActor
final class ArchiveStoreTests: XCTestCase {
func testStoreRoundTrip() async throws {
let jid = "test-\(UUID().uuidString)@example.org"
let store = try ArchiveStore(accountJID: jid)
defer { try? store.erase() }
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
let message = ChatMessage(
conversationID: conversation.jid,
senderJID: jid,
body: "Encrypted hello",
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
direction: .outgoing,
delivery: .sent,
security: .omemo
)
store.context.insert(conversation)
store.context.insert(message)
try store.save(
locallyDeletedMessageIDs: ["deleted-message-id"],
rosterContactJIDs: [conversation.jid],
lastSuccessfulMAMSync: Date(timeIntervalSince1970: 1_700_000_000),
lastSuccessfulMAMCursor: "mam-cursor-42",
mamCheckpoints: [:]
)
let loaded = store.load()
XCTAssertEqual(loaded.conversations.map(\.jid), [conversation.jid])
XCTAssertEqual(loaded.messages.map(\.clientID), [message.clientID])
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set(["deleted-message-id"]))
XCTAssertEqual(loaded.rosterContactJIDs, Set([conversation.jid]))
XCTAssertEqual(
loaded.lastSuccessfulMAMSync,
Date(timeIntervalSince1970: 1_700_000_000)
)
XCTAssertEqual(loaded.lastSuccessfulMAMCursor, "mam-cursor-42")
}
func testLegacyImportMapsConversationAndMessage() throws {
let jid = "test-\(UUID().uuidString)@example.org"
let legacy = LegacyArchiveImporter.Snapshot(
conversations: [
LegacyArchiveImporter.LegacyConversation(
id: "bob@example.org",
jid: "bob@example.org",
displayName: "Bob",
lastMessage: "",
lastActivity: .distantPast,
unreadCount: 0,
isOnline: false,
isPinned: false,
encryptionPreference: .inheritGlobal,
kind: .direct,
groupNickname: nil,
isGroupJoined: false,
shouldAutojoin: false,
occupantCount: 0,
invitedBy: nil
)
],
messages: [
LegacyArchiveImporter.LegacyMessage(
id: "msg-1",
conversationID: "bob@example.org",
senderJID: jid,
body: "Hello",
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
direction: .outgoing,
delivery: .sent,
security: .omemo,
kind: .text
)
]
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
let data = try encoder.encode(legacy)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: url) }
try data.write(to: url)
let imported = try LegacyArchiveImporter.importIfNeeded(from: url)
XCTAssertNotNil(imported)
XCTAssertEqual(imported?.conversations.map(\.jid), ["bob@example.org"])
XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"])
XCTAssertEqual(imported?.messages.first?.conversation?.jid, "bob@example.org")
}
func testLegacyImportToleratesMissingOptionalFields() throws {
// Snapshots written by older schema versions lack fields that were
// added later (reactions, isGroupMessage, rosterContactJIDs, ...).
// The importer must decode them with defaults, exactly like the
// removed ChatArchive did.
let raw: [String: Any] = [
"schemaVersion": 2,
"conversations": [
[
"id": "bob@example.org",
"jid": "bob@example.org",
"displayName": "Bob",
"lastMessage": "",
"lastActivity": 1_700_000_000_000.0,
"unreadCount": 0,
"isOnline": false,
"isPinned": false,
]
],
"messages": [
[
"id": "msg-1",
"conversationID": "bob@example.org",
"senderJID": "alice@example.org",
"body": "Hello",
"timestamp": 1_700_000_000_000.0,
"direction": "outgoing",
"delivery": "sent",
"security": "omemo",
"kind": "text",
]
],
]
let data = try JSONSerialization.data(withJSONObject: raw)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-old-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: url) }
try data.write(to: url)
let imported = try LegacyArchiveImporter.importIfNeeded(from: url)
XCTAssertNotNil(imported)
XCTAssertEqual(imported?.conversations.map(\.jid), ["bob@example.org"])
XCTAssertEqual(imported?.conversations.first?.kind, .direct)
XCTAssertEqual(imported?.conversations.first?.isGroupJoined, false)
XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"])
XCTAssertEqual(imported?.messages.first?.isGroupMessage, false)
XCTAssertEqual(imported?.messages.first?.reactions ?? [], [])
XCTAssertEqual(imported?.rosterContactJIDs ?? [], [])
XCTAssertEqual(imported?.mamCheckpoints ?? [], [])
XCTAssertNil(imported?.lastSuccessfulMAMSync)
}
func testLocallyDeletedRowsArePurgedFromTheStore() throws {
// @Query views read the store directly, so messages that were only
// marked as locally deleted must be physically removed on open.
let jid = "test-\(UUID().uuidString)@example.org"
let store = try ArchiveStore(accountJID: jid)
defer { try? store.erase() }
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
let message = ChatMessage(
conversationID: conversation.jid,
senderJID: jid,
body: "Will be deleted",
direction: .outgoing,
delivery: .sent,
security: .omemo
)
store.context.insert(conversation)
store.context.insert(message)
let deletionKey = ArchiveStore.deletionKey(
messageID: message.clientID,
conversationID: conversation.jid
)
try store.save(
locallyDeletedMessageIDs: [deletionKey],
rosterContactJIDs: [],
lastSuccessfulMAMSync: nil,
lastSuccessfulMAMCursor: nil,
mamCheckpoints: [:]
)
store.purgeLocallyDeletedMessages()
let loaded = store.load()
XCTAssertTrue(loaded.messages.isEmpty)
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set([deletionKey]))
}
func testCorruptLegacyJSONIsKeptForRetry() throws {
let jid = "test-\(UUID().uuidString)@example.org"
let directory = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!.appendingPathComponent("Luma/Accounts", isDirectory: true)
let legacyURL = directory.appendingPathComponent("\(ArchiveStore.stableHash(jid)).json")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data("not-json".utf8).write(to: legacyURL)
defer { try? FileManager.default.removeItem(at: legacyURL) }
let store = try ArchiveStore(accountJID: jid)
defer { try? store.erase() }
XCTAssertTrue(store.load().conversations.isEmpty)
// The unreadable snapshot must stay on disk instead of being deleted
// together with the user's history.
XCTAssertTrue(FileManager.default.fileExists(atPath: legacyURL.path))
}
}