Паритет вложений и поиска на iOS (desktop/server/android), новые autotests и аудит
This commit is contained in:
181
RosettaTests/AttachmentParityTests.swift
Normal file
181
RosettaTests/AttachmentParityTests.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
@MainActor
|
||||
final class AttachmentParityTests: XCTestCase {
|
||||
private var ctx: DBTestContext!
|
||||
private var transportMock: MockAttachmentFlowTransport!
|
||||
private var senderMock: MockPacketFlowSender!
|
||||
|
||||
private var ownPrivateKeyHex: String = ""
|
||||
private var ownPublicKey: String = ""
|
||||
private var peerPublicKey: String = ""
|
||||
|
||||
override func setUpWithError() throws {
|
||||
let ownPair = try Self.makeKeyPair()
|
||||
let peerPair = try Self.makeKeyPair()
|
||||
|
||||
ownPrivateKeyHex = ownPair.privateKeyHex
|
||||
ownPublicKey = ownPair.publicKeyHex
|
||||
peerPublicKey = peerPair.publicKeyHex
|
||||
|
||||
ctx = DBTestContext(account: ownPublicKey)
|
||||
transportMock = MockAttachmentFlowTransport()
|
||||
senderMock = MockPacketFlowSender()
|
||||
|
||||
SessionManager.shared.testConfigureSessionForParityFlows(
|
||||
currentPublicKey: ownPublicKey,
|
||||
privateKeyHex: ownPrivateKeyHex
|
||||
)
|
||||
SessionManager.shared.attachmentFlowTransport = transportMock
|
||||
SessionManager.shared.packetFlowSender = senderMock
|
||||
AttachmentCache.shared.privateKey = ownPrivateKeyHex
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
ctx?.teardown()
|
||||
ctx = nil
|
||||
transportMock = nil
|
||||
senderMock = nil
|
||||
AttachmentCache.shared.privateKey = nil
|
||||
SessionManager.shared.testResetParityFlowDependencies()
|
||||
}
|
||||
|
||||
func testAttachmentPreviewParserMatrix() {
|
||||
let tag = "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb"
|
||||
|
||||
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: "\(tag)::LKO2"), tag)
|
||||
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: "\(tag)::LKO2"), "LKO2")
|
||||
|
||||
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: "::LKO2"), "")
|
||||
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: "::LKO2"), "LKO2")
|
||||
|
||||
let taggedFile = AttachmentPreviewCodec.parseFilePreview("\(tag)::2048::report.pdf")
|
||||
XCTAssertEqual(taggedFile.downloadTag, tag)
|
||||
XCTAssertEqual(taggedFile.fileSize, 2048)
|
||||
XCTAssertEqual(taggedFile.fileName, "report.pdf")
|
||||
|
||||
let localFile = AttachmentPreviewCodec.parseFilePreview("512::notes.txt")
|
||||
XCTAssertEqual(localFile.downloadTag, "")
|
||||
XCTAssertEqual(localFile.fileSize, 512)
|
||||
XCTAssertEqual(localFile.fileName, "notes.txt")
|
||||
|
||||
XCTAssertEqual(AttachmentPreviewCodec.payload(from: "legacy_preview"), "legacy_preview")
|
||||
}
|
||||
|
||||
func testOutgoingAttachmentPacketShapeClearsBlobAndUsesTaggedPreview() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
let image = Self.makeSolidImage(color: .systemBlue)
|
||||
let imageAttachment = PendingAttachment.fromImage(image)
|
||||
let fileData = Data("hello parity".utf8)
|
||||
let fileAttachment = PendingAttachment.fromFile(data: fileData, fileName: "notes.txt")
|
||||
|
||||
let imageTag = "11111111-1111-1111-1111-111111111111"
|
||||
let fileTag = "22222222-2222-2222-2222-222222222222"
|
||||
transportMock.tagsById[imageAttachment.id] = imageTag
|
||||
transportMock.tagsById[fileAttachment.id] = fileTag
|
||||
|
||||
try await SessionManager.shared.sendMessageWithAttachments(
|
||||
text: "",
|
||||
attachments: [imageAttachment, fileAttachment],
|
||||
toPublicKey: peerPublicKey,
|
||||
opponentTitle: "Peer",
|
||||
opponentUsername: "peer"
|
||||
)
|
||||
|
||||
XCTAssertEqual(transportMock.uploadedIds.count, 2)
|
||||
XCTAssertEqual(senderMock.sentMessages.count, 1)
|
||||
|
||||
guard let sent = senderMock.sentMessages.first else {
|
||||
XCTFail("No outgoing packet captured")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(sent.attachments.count, 2)
|
||||
XCTAssertTrue(sent.attachments.allSatisfy { $0.blob.isEmpty })
|
||||
|
||||
guard let sentImage = sent.attachments.first(where: { $0.id == imageAttachment.id }) else {
|
||||
XCTFail("Missing image attachment in packet")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: sentImage.preview), imageTag)
|
||||
|
||||
guard let sentFile = sent.attachments.first(where: { $0.id == fileAttachment.id }) else {
|
||||
XCTFail("Missing file attachment in packet")
|
||||
return
|
||||
}
|
||||
let parsedFile = AttachmentPreviewCodec.parseFilePreview(sentFile.preview)
|
||||
XCTAssertEqual(parsedFile.downloadTag, fileTag)
|
||||
XCTAssertEqual(parsedFile.fileSize, fileData.count)
|
||||
XCTAssertEqual(parsedFile.fileName, "notes.txt")
|
||||
}
|
||||
|
||||
func testSavedSelfFileFlowKeepsLocalFileOpenableWithoutUpload() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
let fileData = Data("self file payload".utf8)
|
||||
let fileAttachment = PendingAttachment.fromFile(data: fileData, fileName: "local.txt")
|
||||
|
||||
try await SessionManager.shared.sendMessageWithAttachments(
|
||||
text: "",
|
||||
attachments: [fileAttachment],
|
||||
toPublicKey: ownPublicKey
|
||||
)
|
||||
|
||||
XCTAssertTrue(transportMock.uploadedIds.isEmpty)
|
||||
XCTAssertTrue(senderMock.sentMessages.isEmpty)
|
||||
|
||||
let cachedURL = AttachmentCache.shared.fileURL(
|
||||
forAttachmentId: fileAttachment.id,
|
||||
fileName: "local.txt"
|
||||
)
|
||||
XCTAssertNotNil(cachedURL)
|
||||
|
||||
let loaded = AttachmentCache.shared.loadFileData(
|
||||
forAttachmentId: fileAttachment.id,
|
||||
fileName: "local.txt"
|
||||
)
|
||||
XCTAssertEqual(loaded, fileData)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AttachmentParityTests {
|
||||
static func makeSolidImage(color: UIColor) -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32))
|
||||
return renderer.image { ctx in
|
||||
color.setFill()
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: 32, height: 32))
|
||||
}
|
||||
}
|
||||
|
||||
static func makeKeyPair() throws -> (privateKeyHex: String, publicKeyHex: String) {
|
||||
let mnemonic = try CryptoManager.shared.generateMnemonic()
|
||||
let pair = try CryptoManager.shared.deriveKeyPair(from: mnemonic)
|
||||
return (pair.privateKey.hexString, pair.publicKey.hexString)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockAttachmentFlowTransport: AttachmentFlowTransporting {
|
||||
var tagsById: [String: String] = [:]
|
||||
private(set) var uploadedIds: [String] = []
|
||||
|
||||
func uploadFile(id: String, content: Data) async throws -> String {
|
||||
uploadedIds.append(id)
|
||||
return tagsById[id] ?? UUID().uuidString.lowercased()
|
||||
}
|
||||
|
||||
func downloadFile(tag: String) async throws -> Data {
|
||||
Data()
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockPacketFlowSender: PacketFlowSending {
|
||||
private(set) var sentMessages: [PacketMessage] = []
|
||||
|
||||
func sendPacket(_ packet: any Packet) {
|
||||
if let message = packet as? PacketMessage {
|
||||
sentMessages.append(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
194
RosettaTests/BehaviorParityFixtureTests.swift
Normal file
194
RosettaTests/BehaviorParityFixtureTests.swift
Normal file
@@ -0,0 +1,194 @@
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
@MainActor
|
||||
final class BehaviorParityFixtureTests: XCTestCase {
|
||||
private var ctx: DBTestContext!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
ctx = DBTestContext()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
ctx.teardown()
|
||||
ctx = nil
|
||||
}
|
||||
|
||||
func testIncomingDirectFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
try await ctx.runScenario(FixtureScenario(name: "incoming direct", events: [
|
||||
.incoming(opponent: "02peer_direct", messageId: "in-1", timestamp: 100, text: "hello"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
XCTAssertEqual(snapshot.messages.count, 1)
|
||||
XCTAssertEqual(snapshot.messages.first?.messageId, "in-1")
|
||||
XCTAssertEqual(snapshot.messages.first?.delivered, DeliveryStatus.delivered.rawValue)
|
||||
XCTAssertEqual(snapshot.messages.first?.read, false)
|
||||
|
||||
XCTAssertEqual(snapshot.dialogs.count, 1)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.opponentKey, "02peer_direct")
|
||||
XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 1)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.iHaveSent, false)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.isRequest, true)
|
||||
}
|
||||
|
||||
func testOutgoingDeliveredReadFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
try await ctx.runScenario(FixtureScenario(name: "outgoing delivered read", events: [
|
||||
.outgoing(opponent: "02peer_ack", messageId: "out-1", timestamp: 200, text: "yo"),
|
||||
.markDelivered(opponent: "02peer_ack", messageId: "out-1"),
|
||||
.markOutgoingRead(opponent: "02peer_ack"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
XCTAssertEqual(snapshot.messages.count, 1)
|
||||
XCTAssertEqual(snapshot.messages.first?.fromMe, true)
|
||||
XCTAssertEqual(snapshot.messages.first?.delivered, DeliveryStatus.delivered.rawValue)
|
||||
XCTAssertEqual(snapshot.messages.first?.read, true)
|
||||
|
||||
XCTAssertEqual(snapshot.dialogs.first?.lastMessageFromMe, true)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.lastMessageDelivered, DeliveryStatus.delivered.rawValue)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.lastMessageRead, true)
|
||||
}
|
||||
|
||||
func testSyncBatchDedupFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
try await ctx.runScenario(FixtureScenario(name: "sync dedup", events: [
|
||||
.incoming(opponent: "02peer_dedup", messageId: "dup-1", timestamp: 300, text: "a"),
|
||||
.incoming(opponent: "02peer_dedup", messageId: "dup-1", timestamp: 300, text: "a"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
XCTAssertEqual(snapshot.messages.count, 1)
|
||||
XCTAssertEqual(snapshot.messages.first?.messageId, "dup-1")
|
||||
}
|
||||
|
||||
func testSavedMessagesFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
try await ctx.runScenario(FixtureScenario(name: "saved", events: [
|
||||
.outgoing(opponent: ctx.account, messageId: "self-1", timestamp: 400, text: "note"),
|
||||
.markDelivered(opponent: ctx.account, messageId: "self-1"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
XCTAssertEqual(snapshot.messages.count, 1)
|
||||
XCTAssertEqual(snapshot.messages.first?.dialogKey, ctx.account)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.opponentKey, ctx.account)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 0)
|
||||
}
|
||||
|
||||
func testGroupConversationDbFlowSafeFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
try await ctx.runScenario(FixtureScenario(name: "group safe", events: [
|
||||
.incomingPacket(
|
||||
from: "02group_member_a",
|
||||
to: "#group:alpha",
|
||||
messageId: "g-1",
|
||||
timestamp: 500,
|
||||
text: "group hi"
|
||||
),
|
||||
.incomingPacket(
|
||||
from: "02conversation_member",
|
||||
to: "conversation:room42",
|
||||
messageId: "c-1",
|
||||
timestamp: 501,
|
||||
text: "conv hi"
|
||||
),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
let groupMessage = snapshot.messages.first { $0.messageId == "g-1" }
|
||||
let conversationMessage = snapshot.messages.first { $0.messageId == "c-1" }
|
||||
|
||||
XCTAssertEqual(groupMessage?.dialogKey, "#group:alpha")
|
||||
XCTAssertEqual(conversationMessage?.dialogKey, "conversation:room42")
|
||||
|
||||
let groupDialog = snapshot.dialogs.first { $0.opponentKey == "#group:alpha" }
|
||||
let conversationDialog = snapshot.dialogs.first { $0.opponentKey == "conversation:room42" }
|
||||
XCTAssertEqual(groupDialog?.iHaveSent, true)
|
||||
XCTAssertEqual(groupDialog?.isRequest, false)
|
||||
XCTAssertEqual(conversationDialog?.iHaveSent, true)
|
||||
XCTAssertEqual(conversationDialog?.isRequest, false)
|
||||
}
|
||||
|
||||
func testAttachmentsOnlyLastMessageFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
let imageAttachment = MessageAttachment(id: "att-1", preview: "", blob: "", type: .image)
|
||||
try await ctx.runScenario(FixtureScenario(name: "attachments only", events: [
|
||||
.incoming(opponent: "02peer_media", messageId: "media-1", timestamp: 600, text: "", attachments: [imageAttachment]),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
XCTAssertEqual(snapshot.messages.first?.hasAttachments, true)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.lastMessage, "Photo")
|
||||
}
|
||||
|
||||
func testGroupReadPacketMarksOutgoingAsReadFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
try await ctx.runScenario(FixtureScenario(name: "group read", events: [
|
||||
.outgoing(opponent: "#group:alpha", messageId: "g-out-1", timestamp: 610, text: "hello group"),
|
||||
.markDelivered(opponent: "#group:alpha", messageId: "g-out-1"),
|
||||
.applyReadPacket(from: "02group_member_b", to: "#group:alpha"),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
let message = snapshot.messages.first { $0.messageId == "g-out-1" }
|
||||
let dialog = snapshot.dialogs.first { $0.opponentKey == "#group:alpha" }
|
||||
|
||||
XCTAssertEqual(message?.fromMe, true)
|
||||
XCTAssertEqual(message?.read, true)
|
||||
XCTAssertEqual(dialog?.lastMessageRead, true)
|
||||
}
|
||||
|
||||
func testCallAttachmentDecodeAndStorageFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
let callAttachment = MessageAttachment(
|
||||
id: "call-1",
|
||||
preview: "",
|
||||
blob: "",
|
||||
type: .call
|
||||
)
|
||||
|
||||
try await ctx.runScenario(FixtureScenario(name: "call attachment", events: [
|
||||
.incomingPacket(
|
||||
from: "02peer_call",
|
||||
to: ctx.account,
|
||||
messageId: "call-msg-1",
|
||||
timestamp: 620,
|
||||
text: "",
|
||||
attachments: [callAttachment]
|
||||
),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
XCTAssertEqual(snapshot.messages.first?.hasAttachments, true)
|
||||
XCTAssertEqual(snapshot.dialogs.first?.lastMessage, "Call")
|
||||
}
|
||||
|
||||
func testRequestToChatPromotionAndCursorMonotonicityFixture() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
try await ctx.runScenario(FixtureScenario(name: "request to chat", events: [
|
||||
.incoming(opponent: "02peer_promote", messageId: "rq-1", timestamp: 700, text: "ping"),
|
||||
.outgoing(opponent: "02peer_promote", messageId: "rq-2", timestamp: 701, text: "pong"),
|
||||
.saveSyncCursor(1_700_000_001_000),
|
||||
.saveSyncCursor(1_700_000_000_900),
|
||||
.saveSyncCursor(1_700_000_001_200),
|
||||
]))
|
||||
|
||||
let snapshot = try ctx.normalizedSnapshot()
|
||||
let dialog = snapshot.dialogs.first { $0.opponentKey == "02peer_promote" }
|
||||
XCTAssertEqual(dialog?.iHaveSent, true)
|
||||
XCTAssertEqual(dialog?.isRequest, false)
|
||||
XCTAssertEqual(snapshot.syncCursor, 1_700_000_001_200)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
RosettaTests/MigrationHarnessTests.swift
Normal file
85
RosettaTests/MigrationHarnessTests.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
@MainActor
|
||||
final class MigrationHarnessTests: XCTestCase {
|
||||
private var ctx: DBTestContext!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
ctx = DBTestContext()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
ctx.teardown()
|
||||
ctx = nil
|
||||
}
|
||||
|
||||
func testLegacySyncOnlyMigrationReconcilesWithoutSQLiteUpsertSyntaxFailure() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
DatabaseManager.shared.close()
|
||||
let sqlite = try ctx.openSQLite()
|
||||
let rerunMigrations = DatabaseManager.migrationIdentifiers.dropFirst(3)
|
||||
let deleteList = rerunMigrations.map { "'\($0)'" }.joined(separator: ",")
|
||||
try sqlite.execute("DELETE FROM grdb_migrations WHERE identifier IN (\(deleteList))")
|
||||
try sqlite.execute("DROP TABLE IF EXISTS accounts_sync_times")
|
||||
try sqlite.execute("DELETE FROM sync_cursors")
|
||||
try sqlite.execute("INSERT INTO sync_cursors(account, timestamp) VALUES ('\(ctx.account)', 1234567890123)")
|
||||
|
||||
try DatabaseManager.shared.bootstrap(accountPublicKey: ctx.account)
|
||||
let cursor = DatabaseManager.shared.loadSyncCursor(account: ctx.account)
|
||||
XCTAssertEqual(cursor, 1_234_567_890_123)
|
||||
}
|
||||
|
||||
func testPartialReconcileBackfillsNullIds() async throws {
|
||||
try await ctx.bootstrap()
|
||||
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 9_001)
|
||||
|
||||
DatabaseManager.shared.close()
|
||||
let sqlite = try ctx.openSQLite()
|
||||
try sqlite.execute("UPDATE accounts_sync_times SET id = NULL WHERE account = '\(ctx.account)'")
|
||||
try sqlite.execute("DELETE FROM grdb_migrations WHERE identifier = '\(DatabaseManager.migrationV7SyncCursorReconcile)'")
|
||||
|
||||
try DatabaseManager.shared.bootstrap(accountPublicKey: ctx.account)
|
||||
|
||||
let check = try ctx.openSQLite()
|
||||
let rows = try check.query(
|
||||
"SELECT id, last_sync FROM accounts_sync_times WHERE account = ? LIMIT 1",
|
||||
[.text(ctx.account)]
|
||||
)
|
||||
XCTAssertEqual(rows.count, 1)
|
||||
XCTAssertNotEqual(rows.first?["id"], "")
|
||||
XCTAssertNotEqual(rows.first?["id"], "0")
|
||||
XCTAssertEqual(rows.first?["last_sync"], "9001")
|
||||
}
|
||||
|
||||
func testMonotonicSyncCursorNeverDecreases() async throws {
|
||||
try await ctx.bootstrap()
|
||||
|
||||
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_005_000)
|
||||
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_004_999)
|
||||
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_006_500)
|
||||
|
||||
XCTAssertEqual(DatabaseManager.shared.loadSyncCursor(account: ctx.account), 1_700_000_006_500)
|
||||
}
|
||||
|
||||
func testCompatibilityMirrorWritesAccountsSyncTimesAndSyncCursors() async throws {
|
||||
try await ctx.bootstrap()
|
||||
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 77_777)
|
||||
|
||||
let sqlite = try ctx.openSQLite()
|
||||
let accountsRows = try sqlite.query(
|
||||
"SELECT last_sync, id FROM accounts_sync_times WHERE account = ? LIMIT 1",
|
||||
[.text(ctx.account)]
|
||||
)
|
||||
let legacyRows = try sqlite.query(
|
||||
"SELECT timestamp FROM sync_cursors WHERE account = ? LIMIT 1",
|
||||
[.text(ctx.account)]
|
||||
)
|
||||
|
||||
XCTAssertEqual(accountsRows.first?["last_sync"], "77777")
|
||||
XCTAssertNotEqual(accountsRows.first?["id"], "")
|
||||
XCTAssertNotEqual(accountsRows.first?["id"], "0")
|
||||
XCTAssertEqual(legacyRows.first?["timestamp"], "77777")
|
||||
}
|
||||
}
|
||||
191
RosettaTests/SchemaParityTests.swift
Normal file
191
RosettaTests/SchemaParityTests.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
@MainActor
|
||||
final class SchemaParityTests: XCTestCase {
|
||||
private var ctx: DBTestContext!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
ctx = DBTestContext()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
ctx.teardown()
|
||||
ctx = nil
|
||||
}
|
||||
|
||||
func testSchemaContainsRequiredTablesColumnsAndIndexes() async throws {
|
||||
try await ctx.bootstrap()
|
||||
let schema = try ctx.schemaSnapshot()
|
||||
|
||||
let requiredTables: Set<String> = [
|
||||
"messages",
|
||||
"dialogs",
|
||||
"accounts_sync_times",
|
||||
"sync_cursors",
|
||||
"groups",
|
||||
"pinned_messages",
|
||||
"avatar_cache",
|
||||
"blacklist",
|
||||
]
|
||||
XCTAssertTrue(requiredTables.isSubset(of: schema.tables), "Missing required tables")
|
||||
|
||||
let messagesColumns = schema.columnsByTable["messages"] ?? []
|
||||
let requiredMessageColumns: Set<String> = [
|
||||
"account", "from_public_key", "to_public_key", "message_id", "dialog_key",
|
||||
"timestamp", "is_read", "read", "delivery_status", "delivered", "text", "plain_message",
|
||||
"attachments", "reply_to_message_id",
|
||||
]
|
||||
XCTAssertTrue(requiredMessageColumns.isSubset(of: messagesColumns), "Missing messages columns")
|
||||
|
||||
let dialogsColumns = schema.columnsByTable["dialogs"] ?? []
|
||||
let requiredDialogColumns: Set<String> = [
|
||||
"account", "opponent_key", "dialog_id", "is_request", "last_timestamp", "last_message_id",
|
||||
"last_message_timestamp", "i_have_sent", "unread_count",
|
||||
]
|
||||
XCTAssertTrue(requiredDialogColumns.isSubset(of: dialogsColumns), "Missing dialogs columns")
|
||||
|
||||
let requiredIndexes: Set<String> = [
|
||||
"idx_messages_account_message_id",
|
||||
"idx_messages_account_dialog_key_timestamp",
|
||||
"idx_messages_account_dialog_fromme_isread",
|
||||
"idx_messages_account_dialog_fromme_timestamp",
|
||||
"idx_dialogs_account_opponent_key",
|
||||
]
|
||||
XCTAssertTrue(requiredIndexes.isSubset(of: schema.indexes), "Missing required indexes")
|
||||
}
|
||||
|
||||
func testUnreadAndSentQueriesUseParityIndexes() async throws {
|
||||
try await ctx.bootstrap()
|
||||
try await ctx.runScenario(FixtureScenario(name: "seed", events: [
|
||||
.incoming(opponent: "02peer_a", messageId: "m1", timestamp: 1, text: "hello"),
|
||||
.outgoing(opponent: "02peer_a", messageId: "m2", timestamp: 2, text: "yo"),
|
||||
]))
|
||||
|
||||
let sqlite = try ctx.openSQLite()
|
||||
let unreadPlanRows = try sqlite.query(
|
||||
"""
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT COUNT(*) FROM messages
|
||||
WHERE account = ? AND dialog_key = ? AND from_me = 0 AND is_read = 0
|
||||
""",
|
||||
[.text(ctx.account), .text(DatabaseManager.dialogKey(account: ctx.account, opponentKey: "02peer_a"))]
|
||||
)
|
||||
let unreadPlan = unreadPlanRows.compactMap { $0["detail"] }.joined(separator: " | ")
|
||||
XCTAssertTrue(
|
||||
unreadPlan.contains("idx_messages_account_dialog_fromme_isread"),
|
||||
"Unread query plan did not use idx_messages_account_dialog_fromme_isread: \(unreadPlan)"
|
||||
)
|
||||
|
||||
let sentPlanRows = try sqlite.query(
|
||||
"""
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT message_id FROM messages
|
||||
WHERE account = ? AND dialog_key = ? AND from_me = 1
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
[.text(ctx.account), .text(DatabaseManager.dialogKey(account: ctx.account, opponentKey: "02peer_a"))]
|
||||
)
|
||||
let sentPlan = sentPlanRows.compactMap { $0["detail"] }.joined(separator: " | ")
|
||||
XCTAssertTrue(
|
||||
sentPlan.contains("idx_messages_account_dialog_fromme_timestamp"),
|
||||
"Sent query plan did not use idx_messages_account_dialog_fromme_timestamp: \(sentPlan)"
|
||||
)
|
||||
}
|
||||
|
||||
func testPacketRegistrySupportsMessagingAndGroupsCoreIds() throws {
|
||||
let packets: [(Int, any Packet)] = [
|
||||
(0x00, PacketHandshake()),
|
||||
(0x01, PacketUserInfo()),
|
||||
(0x02, PacketResult()),
|
||||
(0x03, PacketSearch()),
|
||||
(0x04, PacketOnlineSubscribe()),
|
||||
(0x05, PacketOnlineState()),
|
||||
(0x06, PacketMessage()),
|
||||
(0x07, PacketRead()),
|
||||
(0x08, PacketDelivery()),
|
||||
(0x09, PacketDeviceNew()),
|
||||
(0x0A, PacketRequestUpdate()),
|
||||
(0x0B, PacketTyping()),
|
||||
(0x0F, PacketRequestTransport()),
|
||||
(0x10, PacketPushNotification()),
|
||||
(0x11, PacketCreateGroup()),
|
||||
(0x12, PacketGroupInfo()),
|
||||
(0x13, PacketGroupInviteInfo()),
|
||||
(0x14, PacketGroupJoin()),
|
||||
(0x15, PacketGroupLeave()),
|
||||
(0x16, PacketGroupBan()),
|
||||
(0x17, PacketDeviceList()),
|
||||
(0x18, PacketDeviceResolve()),
|
||||
(0x19, PacketSync()),
|
||||
]
|
||||
|
||||
for (expectedId, packet) in packets {
|
||||
let encoded = PacketRegistry.encode(packet)
|
||||
guard let decoded = PacketRegistry.decode(from: encoded) else {
|
||||
XCTFail("Failed to decode packet 0x\(String(expectedId, radix: 16))")
|
||||
continue
|
||||
}
|
||||
XCTAssertEqual(decoded.packetId, expectedId)
|
||||
}
|
||||
}
|
||||
|
||||
func testAttachmentTypeCallRoundTripDecoding() throws {
|
||||
var packet = PacketMessage()
|
||||
packet.fromPublicKey = "02from"
|
||||
packet.toPublicKey = "02to"
|
||||
packet.content = ""
|
||||
packet.chachaKey = ""
|
||||
packet.timestamp = 123
|
||||
packet.privateKey = "hash"
|
||||
packet.messageId = "msg-call"
|
||||
packet.attachments = [MessageAttachment(id: "call-1", preview: "", blob: "", type: .call)]
|
||||
packet.aesChachaKey = ""
|
||||
|
||||
let encoded = PacketRegistry.encode(packet)
|
||||
guard let decoded = PacketRegistry.decode(from: encoded),
|
||||
let decodedMessage = decoded.packet as? PacketMessage
|
||||
else {
|
||||
XCTFail("Failed to decode call attachment packet")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.packetId, 0x06)
|
||||
XCTAssertEqual(decodedMessage.attachments.first?.type, .call)
|
||||
}
|
||||
|
||||
func testSessionPacketContextResolverAcceptsGroupWireShape() throws {
|
||||
let own = "02my_account"
|
||||
|
||||
var groupMessage = PacketMessage()
|
||||
groupMessage.fromPublicKey = "02group_member"
|
||||
groupMessage.toPublicKey = "#group:alpha"
|
||||
let messageContext = SessionManager.testResolveMessagePacketContext(groupMessage, ownKey: own)
|
||||
XCTAssertEqual(messageContext?.kind, "group")
|
||||
XCTAssertEqual(messageContext?.dialogKey, "#group:alpha")
|
||||
XCTAssertEqual(messageContext?.fromMe, false)
|
||||
|
||||
var groupRead = PacketRead()
|
||||
groupRead.fromPublicKey = "02group_member"
|
||||
groupRead.toPublicKey = "#group:alpha"
|
||||
let readContext = SessionManager.testResolveReadPacketContext(groupRead, ownKey: own)
|
||||
XCTAssertEqual(readContext?.kind, "group")
|
||||
XCTAssertEqual(readContext?.dialogKey, "#group:alpha")
|
||||
XCTAssertEqual(readContext?.fromMe, false)
|
||||
|
||||
var groupTyping = PacketTyping()
|
||||
groupTyping.fromPublicKey = "02group_member"
|
||||
groupTyping.toPublicKey = "#group:alpha"
|
||||
let typingContext = SessionManager.testResolveTypingPacketContext(groupTyping, ownKey: own)
|
||||
XCTAssertEqual(typingContext?.kind, "group")
|
||||
XCTAssertEqual(typingContext?.dialogKey, "#group:alpha")
|
||||
XCTAssertEqual(typingContext?.fromMe, false)
|
||||
}
|
||||
|
||||
func testStreamEncodingSmoke() {
|
||||
let stream = Rosetta.Stream()
|
||||
_ = stream
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
126
RosettaTests/SearchParityTests.swift
Normal file
126
RosettaTests/SearchParityTests.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
@MainActor
|
||||
final class SearchParityTests: XCTestCase {
|
||||
func testSearchViewModelAndChatListUseSameQueryNormalization() async {
|
||||
let searchDispatcher = MockSearchDispatcher()
|
||||
let chatDispatcher = MockSearchDispatcher()
|
||||
let searchVM = SearchViewModel(searchDispatcher: searchDispatcher)
|
||||
let chatVM = ChatListViewModel(searchDispatcher: chatDispatcher)
|
||||
|
||||
searchVM.setSearchQuery(" @Alice ")
|
||||
chatVM.setSearchQuery(" @Alice ")
|
||||
|
||||
try? await Task.sleep(for: .milliseconds(1200))
|
||||
|
||||
XCTAssertEqual(searchDispatcher.sentQueries, ["alice"])
|
||||
XCTAssertEqual(chatDispatcher.sentQueries, ["alice"])
|
||||
}
|
||||
|
||||
func testSavedAliasesAndExactPublicKeyFallback() throws {
|
||||
let ownPair = try Self.makeKeyPair()
|
||||
let peerPair = try Self.makeKeyPair()
|
||||
|
||||
let dialog = Self.makeDialog(
|
||||
account: ownPair.publicKeyHex,
|
||||
opponentKey: peerPair.publicKeyHex,
|
||||
username: "peer_user",
|
||||
title: "Peer User"
|
||||
)
|
||||
|
||||
let saved = SearchParityPolicy.localAugmentedUsers(
|
||||
query: "Saved Messages",
|
||||
currentPublicKey: ownPair.publicKeyHex,
|
||||
dialogs: [dialog]
|
||||
)
|
||||
XCTAssertEqual(saved.count, 1)
|
||||
XCTAssertEqual(saved.first?.publicKey, ownPair.publicKeyHex)
|
||||
XCTAssertEqual(saved.first?.title, "Saved Messages")
|
||||
|
||||
let exactPeer = SearchParityPolicy.localAugmentedUsers(
|
||||
query: "0x" + peerPair.publicKeyHex,
|
||||
currentPublicKey: ownPair.publicKeyHex,
|
||||
dialogs: [dialog]
|
||||
)
|
||||
XCTAssertEqual(exactPeer.count, 1)
|
||||
XCTAssertEqual(exactPeer.first?.publicKey, peerPair.publicKeyHex)
|
||||
XCTAssertEqual(exactPeer.first?.username, "peer_user")
|
||||
}
|
||||
|
||||
func testServerAndLocalMergeDedupesByPublicKeyWithServerPriority() {
|
||||
let key = "021111111111111111111111111111111111111111111111111111111111111111"
|
||||
let localOnlyKey = "022222222222222222222222222222222222222222222222222222222222222222"
|
||||
|
||||
let server = [
|
||||
SearchUser(username: "server_u", title: "Server Name", publicKey: key, verified: 2, online: 0),
|
||||
]
|
||||
let local = [
|
||||
SearchUser(username: "local_u", title: "Local Name", publicKey: key, verified: 0, online: 1),
|
||||
SearchUser(username: "local_only", title: "Local Only", publicKey: localOnlyKey, verified: 0, online: 1),
|
||||
]
|
||||
|
||||
let merged = SearchParityPolicy.mergeServerAndLocal(server: server, local: local)
|
||||
XCTAssertEqual(merged.count, 2)
|
||||
XCTAssertEqual(merged[0].publicKey, key)
|
||||
XCTAssertEqual(merged[0].title, "Server Name")
|
||||
XCTAssertEqual(merged[1].publicKey, localOnlyKey)
|
||||
}
|
||||
}
|
||||
|
||||
private extension SearchParityTests {
|
||||
static func makeKeyPair() throws -> (privateKeyHex: String, publicKeyHex: String) {
|
||||
let mnemonic = try CryptoManager.shared.generateMnemonic()
|
||||
let pair = try CryptoManager.shared.deriveKeyPair(from: mnemonic)
|
||||
return (pair.privateKey.hexString, pair.publicKey.hexString)
|
||||
}
|
||||
|
||||
static func makeDialog(
|
||||
account: String,
|
||||
opponentKey: String,
|
||||
username: String,
|
||||
title: String
|
||||
) -> Dialog {
|
||||
Dialog(
|
||||
id: UUID().uuidString,
|
||||
account: account,
|
||||
opponentKey: opponentKey,
|
||||
opponentTitle: title,
|
||||
opponentUsername: username,
|
||||
lastMessage: "",
|
||||
lastMessageTimestamp: 0,
|
||||
unreadCount: 0,
|
||||
isOnline: true,
|
||||
lastSeen: 0,
|
||||
verified: 0,
|
||||
iHaveSent: true,
|
||||
isPinned: false,
|
||||
isMuted: false,
|
||||
lastMessageFromMe: false,
|
||||
lastMessageDelivered: .delivered,
|
||||
lastMessageRead: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockSearchDispatcher: SearchResultDispatching {
|
||||
var connectionState: ConnectionState = .authenticated
|
||||
var privateHash: String? = "mock-private-hash"
|
||||
private(set) var sentQueries: [String] = []
|
||||
private var handlers: [UUID: (PacketSearch) -> Void] = [:]
|
||||
|
||||
func sendSearchPacket(_ packet: PacketSearch) {
|
||||
sentQueries.append(packet.search)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID {
|
||||
let id = UUID()
|
||||
handlers[id] = handler
|
||||
return id
|
||||
}
|
||||
|
||||
func removeSearchResultHandler(_ id: UUID) {
|
||||
handlers.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user