import Foundation import SQLite3 import XCTest @testable import Rosetta struct SchemaSnapshot { let tables: Set let columnsByTable: [String: Set] let indexes: Set } struct NormalizedDbSnapshot: Equatable { struct Message: Equatable { let account: String let dialogKey: String let messageId: String let fromMe: Bool let read: Bool let delivered: Int let plainMessage: String let timestamp: Int64 let hasAttachments: Bool } struct Dialog: Equatable { let opponentKey: String let lastMessage: String let lastMessageTimestamp: Int64 let unreadCount: Int let iHaveSent: Bool let isRequest: Bool let lastMessageFromMe: Bool let lastMessageDelivered: Int let lastMessageRead: Bool } let messages: [Message] let dialogs: [Dialog] let syncCursor: Int64 } enum FixtureEvent { case incoming(opponent: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = []) case incomingPacket(from: String, to: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = []) case outgoing(opponent: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = []) case markDelivered(opponent: String, messageId: String) case markOutgoingRead(opponent: String) case applyReadPacket(from: String, to: String) case saveSyncCursor(Int64) } struct FixtureScenario { let name: String let events: [FixtureEvent] } final class SQLiteTestDB { private var handle: OpaquePointer? init(path: String) throws { if sqlite3_open(path, &handle) != SQLITE_OK { let message = String(cString: sqlite3_errmsg(handle)) sqlite3_close(handle) throw NSError(domain: "SQLiteTestDB", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) } } deinit { sqlite3_close(handle) } func execute(_ sql: String) throws { var errorMessage: UnsafeMutablePointer? if sqlite3_exec(handle, sql, nil, nil, &errorMessage) != SQLITE_OK { let message = errorMessage.map { String(cString: $0) } ?? "Unknown sqlite error" sqlite3_free(errorMessage) throw NSError(domain: "SQLiteTestDB", code: 2, userInfo: [NSLocalizedDescriptionKey: message, "sql": sql]) } } func query(_ sql: String, _ bindings: [Binding] = []) throws -> [[String: String]] { var statement: OpaquePointer? guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK else { let message = String(cString: sqlite3_errmsg(handle)) throw NSError(domain: "SQLiteTestDB", code: 3, userInfo: [NSLocalizedDescriptionKey: message, "sql": sql]) } defer { sqlite3_finalize(statement) } for (index, binding) in bindings.enumerated() { let idx = Int32(index + 1) switch binding { case .text(let value): sqlite3_bind_text(statement, idx, value, -1, SQLITE_TRANSIENT) case .int64(let value): sqlite3_bind_int64(statement, idx, value) } } var rows: [[String: String]] = [] while sqlite3_step(statement) == SQLITE_ROW { let columnCount = sqlite3_column_count(statement) var row: [String: String] = [:] for column in 0.. SQLiteTestDB { try SQLiteTestDB(path: databaseURL.path) } func schemaSnapshot() throws -> SchemaSnapshot { let sqlite = try openSQLite() let tableRows = try sqlite.query("SELECT name FROM sqlite_master WHERE type='table'") let tables = Set(tableRows.compactMap { $0["name"] }) var columnsByTable: [String: Set] = [:] for table in tables { let rows = try sqlite.query("PRAGMA table_info('\(table)')") columnsByTable[table] = Set(rows.compactMap { $0["name"] }) } let indexRows = try sqlite.query("SELECT name FROM sqlite_master WHERE type='index'") let indexes = Set(indexRows.compactMap { $0["name"] }) return SchemaSnapshot(tables: tables, columnsByTable: columnsByTable, indexes: indexes) } func normalizedSnapshot() throws -> NormalizedDbSnapshot { let sqlite = try openSQLite() let messageRows = try sqlite.query( """ SELECT account, dialog_key, message_id, from_me, is_read, delivery_status, COALESCE(NULLIF(plain_message, ''), text) AS plain_message, timestamp, attachments FROM messages WHERE account = ? ORDER BY dialog_key, timestamp, message_id """, [.text(account)] ) let messages = messageRows.map { row in NormalizedDbSnapshot.Message( account: row["account", default: ""], dialogKey: row["dialog_key", default: ""], messageId: row["message_id", default: ""], fromMe: row["from_me"] == "1", read: row["is_read"] == "1", delivered: Int(row["delivery_status", default: "0"]) ?? 0, plainMessage: row["plain_message", default: ""], timestamp: Int64(row["timestamp", default: "0"]) ?? 0, hasAttachments: row["attachments", default: "[]"] != "[]" ) } let dialogRows = try sqlite.query( """ SELECT opponent_key, last_message, last_message_timestamp, unread_count, i_have_sent, is_request, last_message_from_me, last_message_delivered, last_message_read FROM dialogs WHERE account = ? ORDER BY opponent_key """, [.text(account)] ) let dialogs = dialogRows.map { row in NormalizedDbSnapshot.Dialog( opponentKey: row["opponent_key", default: ""], lastMessage: row["last_message", default: ""], lastMessageTimestamp: Int64(row["last_message_timestamp", default: "0"]) ?? 0, unreadCount: Int(row["unread_count", default: "0"]) ?? 0, iHaveSent: row["i_have_sent"] == "1", isRequest: row["is_request"] == "1", lastMessageFromMe: row["last_message_from_me"] == "1", lastMessageDelivered: Int(row["last_message_delivered", default: "0"]) ?? 0, lastMessageRead: row["last_message_read"] == "1" ) } let cursorRow = try sqlite.query( "SELECT last_sync FROM accounts_sync_times WHERE account = ? LIMIT 1", [.text(account)] ).first let syncCursor = Int64(cursorRow?["last_sync"] ?? "0") ?? 0 return NormalizedDbSnapshot(messages: messages, dialogs: dialogs, syncCursor: syncCursor) } func runScenario(_ scenario: FixtureScenario) async throws { func normalizeGroupDialogKey(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let lower = trimmed.lowercased() if lower.hasPrefix("group:") { let id = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) return id.isEmpty ? trimmed : "#group:\(id)" } return trimmed } func resolveDialogIdentity(from: String, to: String) -> String? { let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines) let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines) guard !fromKey.isEmpty, !toKey.isEmpty else { return nil } if DatabaseManager.isGroupDialogKey(toKey) { return normalizeGroupDialogKey(toKey) } if fromKey == account { return toKey } if toKey == account { return fromKey } return nil } for event in scenario.events { switch event { case .incoming(let opponent, let messageId, let timestamp, let text, let attachments): var packet = PacketMessage() packet.fromPublicKey = opponent packet.toPublicKey = account packet.messageId = messageId packet.timestamp = timestamp packet.attachments = attachments MessageRepository.shared.upsertFromMessagePacket(packet, myPublicKey: account, decryptedText: text) DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent) case .incomingPacket(let from, let to, let messageId, let timestamp, let text, let attachments): guard let dialogIdentity = resolveDialogIdentity(from: from, to: to) else { continue } var packet = PacketMessage() packet.fromPublicKey = from packet.toPublicKey = to packet.messageId = messageId packet.timestamp = timestamp packet.attachments = attachments MessageRepository.shared.upsertFromMessagePacket( packet, myPublicKey: account, decryptedText: text, dialogIdentityOverride: dialogIdentity ) DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogIdentity) case .outgoing(let opponent, let messageId, let timestamp, let text, let attachments): var packet = PacketMessage() packet.fromPublicKey = account packet.toPublicKey = opponent packet.messageId = messageId packet.timestamp = timestamp packet.attachments = attachments MessageRepository.shared.upsertFromMessagePacket(packet, myPublicKey: account, decryptedText: text) DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent) case .markDelivered(let opponent, let messageId): MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent) case .markOutgoingRead(let opponent): MessageRepository.shared.markOutgoingAsRead(opponentKey: opponent, myPublicKey: account) DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent) case .applyReadPacket(let from, let to): let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines) let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines) guard !fromKey.isEmpty, !toKey.isEmpty else { continue } if DatabaseManager.isGroupDialogKey(toKey) { let dialogIdentity = normalizeGroupDialogKey(toKey) if fromKey == account { MessageRepository.shared.markIncomingAsRead(opponentKey: dialogIdentity, myPublicKey: account) DialogRepository.shared.markAsRead(opponentKey: dialogIdentity) } else { MessageRepository.shared.markOutgoingAsRead(opponentKey: dialogIdentity, myPublicKey: account) DialogRepository.shared.markOutgoingAsRead(opponentKey: dialogIdentity) } continue } if fromKey == account { MessageRepository.shared.markIncomingAsRead(opponentKey: toKey, myPublicKey: account) DialogRepository.shared.markAsRead(opponentKey: toKey) } else if toKey == account { MessageRepository.shared.markOutgoingAsRead(opponentKey: fromKey, myPublicKey: account) DialogRepository.shared.markOutgoingAsRead(opponentKey: fromKey) } case .saveSyncCursor(let timestamp): DatabaseManager.shared.saveSyncCursor(account: account, timestamp: timestamp) } } } }