Паритет вложений и поиска на iOS (desktop/server/android), новые autotests и аудит
This commit is contained in:
334
RosettaTests/DBTestSupport.swift
Normal file
334
RosettaTests/DBTestSupport.swift
Normal file
@@ -0,0 +1,334 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
struct SchemaSnapshot {
|
||||
let tables: Set<String>
|
||||
let columnsByTable: [String: Set<String>]
|
||||
let indexes: Set<String>
|
||||
}
|
||||
|
||||
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<Int8>?
|
||||
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..<columnCount {
|
||||
let name = String(cString: sqlite3_column_name(statement, column))
|
||||
if let valuePtr = sqlite3_column_text(statement, column) {
|
||||
row[name] = String(cString: valuePtr)
|
||||
} else {
|
||||
row[name] = ""
|
||||
}
|
||||
}
|
||||
rows.append(row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
enum Binding {
|
||||
case text(String)
|
||||
case int64(Int64)
|
||||
}
|
||||
}
|
||||
|
||||
private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
|
||||
@MainActor
|
||||
final class DBTestContext {
|
||||
let account: String
|
||||
let storagePassword = "test-storage-password"
|
||||
|
||||
init(account: String = "02_ios_sql_test_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))") {
|
||||
self.account = account
|
||||
}
|
||||
|
||||
var databaseURL: URL {
|
||||
DatabaseManager.databaseURLForTesting(accountPublicKey: account)
|
||||
}
|
||||
|
||||
func bootstrap() async throws {
|
||||
try DatabaseManager.shared.bootstrap(accountPublicKey: account)
|
||||
await MessageRepository.shared.bootstrap(accountPublicKey: account, storagePassword: storagePassword)
|
||||
await DialogRepository.shared.bootstrap(accountPublicKey: account, storagePassword: storagePassword)
|
||||
}
|
||||
|
||||
func teardown() {
|
||||
MessageRepository.shared.reset()
|
||||
DialogRepository.shared.reset()
|
||||
DatabaseManager.shared.close()
|
||||
DatabaseManager.shared.deleteDatabase(for: account)
|
||||
}
|
||||
|
||||
func openSQLite() throws -> 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<String>] = [:]
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user