mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
1180 lines
58 KiB
Swift
1180 lines
58 KiB
Swift
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import SessionNetworkingKit
|
|
import SessionUtilitiesKit
|
|
|
|
import Quick
|
|
import Nimble
|
|
|
|
@testable import SessionMessagingKit
|
|
|
|
class MessageDeduplicationSpec: AsyncSpec {
|
|
override class func spec() {
|
|
// MARK: Configuration
|
|
|
|
@TestState var dependencies: TestDependencies! = TestDependencies()
|
|
|
|
@TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage(
|
|
customWriter: try! DatabaseQueue(),
|
|
migrations: SNMessagingKit.migrations,
|
|
using: dependencies
|
|
)
|
|
@TestState(singleton: .extensionHelper, in: dependencies) var mockExtensionHelper: MockExtensionHelper! = MockExtensionHelper(
|
|
initialSetup: { helper in
|
|
helper.when { $0.deleteCache() }.thenReturn(())
|
|
helper
|
|
.when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenReturn(false)
|
|
helper
|
|
.when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenReturn(())
|
|
helper
|
|
.when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenReturn(())
|
|
helper
|
|
.when { try $0.upsertLastClearedRecord(threadId: .any) }
|
|
.thenReturn(())
|
|
}
|
|
)
|
|
@TestState var mockMessage: Message! = {
|
|
let result: ReadReceipt = ReadReceipt(timestamps: [1])
|
|
result.sentTimestampMs = 12345678901234
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - MessageDeduplication - Inserting
|
|
describe("MessageDeduplication") {
|
|
// MARK: -- when inserting
|
|
context("when inserting") {
|
|
// MARK: ---- inserts a record correctly
|
|
it("inserts a record correctly") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: "testId",
|
|
message: nil,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let expectedTimestamp: Int64 = (1234567890 + ((SnodeReceivedMessage.serverClockToleranceMs * 2) / 1000))
|
|
let records: [MessageDeduplication]? = mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) }
|
|
expect(records?.count).to(equal(1))
|
|
expect(records?.first?.threadId).to(equal("testThreadId"))
|
|
expect(records?.first?.uniqueIdentifier).to(equal("testId"))
|
|
expect(records?.first?.expirationTimestampSeconds).to(equal(expectedTimestamp))
|
|
expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- checks that it is not a duplicate record
|
|
it("checks that it is not a duplicate record") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: "testId",
|
|
message: nil,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
$0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- creates a legacy record if needed
|
|
it("creates a legacy record if needed") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
$0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- sets the shouldDeleteWhenDeletingThread flag correctly
|
|
it("sets the shouldDeleteWhenDeletingThread flag correctly") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: "testId1",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .community,
|
|
uniqueIdentifier: "testId2",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .legacyGroup,
|
|
uniqueIdentifier: "testId3",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .group,
|
|
uniqueIdentifier: "testId4",
|
|
message: GroupUpdateInviteMessage(
|
|
inviteeSessionIdHexString: "TestId",
|
|
groupSessionId: SessionId(.group, hex: TestConstants.publicKey),
|
|
groupName: "TestGroup",
|
|
memberAuthData: Data([1, 2, 3]),
|
|
profile: nil,
|
|
adminSignature: .standard(signature: "TestSignature".bytes)
|
|
),
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .group,
|
|
uniqueIdentifier: "testId5",
|
|
message: GroupUpdatePromoteMessage(
|
|
groupIdentitySeed: Data([1, 2, 3]),
|
|
groupName: "TestGroup",
|
|
sentTimestampMs: 1234567890000
|
|
),
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .group,
|
|
uniqueIdentifier: "testId6",
|
|
message: GroupUpdateMemberLeftMessage(),
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .group,
|
|
uniqueIdentifier: "testId7",
|
|
message: GroupUpdateInviteResponseMessage(
|
|
isApproved: true,
|
|
profile: nil,
|
|
sentTimestampMs: 1234567800000
|
|
),
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .group,
|
|
uniqueIdentifier: "testId8",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: nil,
|
|
uniqueIdentifier: "testId9",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: nil,
|
|
uniqueIdentifier: "testId10",
|
|
message: nil,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [String: MessageDeduplication] = mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) }
|
|
.defaulting(to: [])
|
|
.reduce(into: [:]) { result, next in result[next.uniqueIdentifier] = next }
|
|
expect(records["testId1"]?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(records["testId2"]?.shouldDeleteWhenDeletingThread).to(beTrue())
|
|
expect(records["testId3"]?.shouldDeleteWhenDeletingThread).to(beTrue())
|
|
expect(records["testId4"]?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(records["testId5"]?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(records["testId6"]?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(records["testId7"]?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(records["testId8"]?.shouldDeleteWhenDeletingThread).to(beTrue())
|
|
expect(records["testId9"]?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(records["testId10"]?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
}
|
|
|
|
// MARK: ---- does nothing if no uniqueIdentifier is provided
|
|
it("does nothing if no uniqueIdentifier is provided") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: nil,
|
|
legacyIdentifier: "testLegacyId",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [MessageDeduplication]? = mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) }
|
|
expect(records).to(beEmpty())
|
|
}
|
|
|
|
// MARK: ---- creates a record from a ProcessedMessage
|
|
it("creates a record from a ProcessedMessage") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
processedMessage: .standard(
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
proto: try! SNProtoContent.builder().build(),
|
|
messageInfo: MessageReceiveJob.Details.MessageInfo(
|
|
message: mockMessage,
|
|
variant: .readReceipt,
|
|
threadVariant: .contact,
|
|
serverExpirationTimestamp: nil,
|
|
proto: try! SNProtoContent.builder().build()
|
|
),
|
|
uniqueIdentifier: "testId"
|
|
),
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "LegacyRecord-1-12345678901234"
|
|
)
|
|
})
|
|
}
|
|
|
|
// MARK: ---- does not create records for config ProcessedMessages
|
|
it("does not create records for config ProcessedMessages") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
processedMessage: .config(
|
|
publicKey: "testThreadId",
|
|
namespace: .configContacts,
|
|
serverHash: "1234",
|
|
serverTimestampMs: 1234567890,
|
|
data: Data([1, 2, 3]),
|
|
uniqueIdentifier: "testId"
|
|
),
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [MessageDeduplication]? = mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) }
|
|
expect(records).to(beEmpty())
|
|
expect(mockExtensionHelper).toNot(call {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- throws if the message is a duplicate
|
|
it("throws if the message is a duplicate") {
|
|
mockExtensionHelper
|
|
.when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenReturn(true)
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(MessageReceiverError.duplicateMessage))
|
|
}
|
|
}
|
|
|
|
// MARK: ---- throws if the message is a legacy duplicate
|
|
it("throws if the message is a legacy duplicate") {
|
|
mockExtensionHelper
|
|
.when {
|
|
$0.dedupeRecordExists(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId"
|
|
)
|
|
}
|
|
.thenReturn(false)
|
|
mockExtensionHelper
|
|
.when {
|
|
$0.dedupeRecordExists(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testLegacyId"
|
|
)
|
|
}
|
|
.thenReturn(true)
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(MessageReceiverError.duplicateMessage))
|
|
}
|
|
}
|
|
|
|
// MARK: ---- throws if it fails to create the dedupe file
|
|
it("throws if it fails to create the dedupe file") {
|
|
mockExtensionHelper
|
|
.when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenThrow(TestError.mock)
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(TestError.mock))
|
|
}
|
|
}
|
|
|
|
// MARK: ---- throws if it fails to create the legacy dedupe file
|
|
it("throws if it fails to create the legacy dedupe file") {
|
|
mockExtensionHelper
|
|
.when {
|
|
try $0.createDedupeRecord(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testLegacyId"
|
|
)
|
|
}
|
|
.thenThrow(TestError.mock)
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insert(
|
|
db,
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
message: mockMessage,
|
|
serverExpirationTimestamp: 1234567890,
|
|
ignoreDedupeFiles: false,
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(TestError.mock))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -- when inserting a call message
|
|
context("when inserting a call message") {
|
|
// MARK: ---- inserts a preOffer record correctly
|
|
it("inserts a preOffer record correctly") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insertCallDedupeRecordsIfNeeded(
|
|
db,
|
|
threadId: "testThreadId",
|
|
callMessage: CallMessage(
|
|
uuid: "12345",
|
|
kind: .preOffer,
|
|
sdps: [],
|
|
sentTimestampMs: 1234567890
|
|
),
|
|
expirationTimestampSeconds: 1234567891,
|
|
shouldDeleteWhenDeletingThread: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [MessageDeduplication]? = mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) }
|
|
expect(records?.count).to(equal(1))
|
|
expect(records?.first?.threadId).to(equal("testThreadId"))
|
|
expect(records?.first?.uniqueIdentifier).to(equal("12345-preOffer"))
|
|
expect(records?.first?.expirationTimestampSeconds).to(equal(1234567891))
|
|
expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- inserts a generic record correctly
|
|
it("inserts a generic record correctly") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insertCallDedupeRecordsIfNeeded(
|
|
db,
|
|
threadId: "testThreadId",
|
|
callMessage: CallMessage(
|
|
uuid: "12345",
|
|
kind: .endCall,
|
|
sdps: [],
|
|
sentTimestampMs: 1234567890
|
|
),
|
|
expirationTimestampSeconds: 1234567891,
|
|
shouldDeleteWhenDeletingThread: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [MessageDeduplication]? = mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) }
|
|
expect(records?.count).to(equal(1))
|
|
expect(records?.first?.threadId).to(equal("testThreadId"))
|
|
expect(records?.first?.uniqueIdentifier).to(equal("12345"))
|
|
expect(records?.first?.expirationTimestampSeconds).to(equal(1234567891))
|
|
expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse())
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- does nothing if no call message is provided
|
|
it("does nothing if no call message is provided") {
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.insertCallDedupeRecordsIfNeeded(
|
|
db,
|
|
threadId: "testThreadId",
|
|
callMessage: nil,
|
|
expirationTimestampSeconds: 1234567891,
|
|
shouldDeleteWhenDeletingThread: false,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [MessageDeduplication]? = mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) }
|
|
expect(records?.count).to(equal(0))
|
|
expect(mockExtensionHelper).toNot(call {
|
|
try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MessageDeduplication - Deleting
|
|
describe("MessageDeduplication") {
|
|
// MARK: -- when deleting a dedupe record
|
|
context("when deleting a dedupe record") {
|
|
// MARK: ---- deletes the record successfully
|
|
it("deletes the record successfully") {
|
|
mockStorage.write { db in
|
|
try MessageDeduplication(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
expirationTimestampSeconds: nil,
|
|
shouldDeleteWhenDeletingThread: true
|
|
).insert(db)
|
|
}
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.deleteIfNeeded(
|
|
db,
|
|
threadIds: ["testThreadId"],
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
await expect(mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) })
|
|
.toEventually(beEmpty())
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- upserts the last cleared record
|
|
it("upserts the last cleared record") {
|
|
mockStorage.write { db in
|
|
try MessageDeduplication(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
expirationTimestampSeconds: nil,
|
|
shouldDeleteWhenDeletingThread: true
|
|
).insert(db)
|
|
}
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.deleteIfNeeded(
|
|
db,
|
|
threadIds: ["testThreadId"],
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
await expect(mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) })
|
|
.toEventually(beEmpty())
|
|
await expect(mockExtensionHelper).toEventually(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.upsertLastClearedRecord(threadId: "testThreadId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- deletes multiple records
|
|
it("deletes multiple records") {
|
|
mockStorage.write { db in
|
|
try MessageDeduplication(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
expirationTimestampSeconds: nil,
|
|
shouldDeleteWhenDeletingThread: true
|
|
).insert(db)
|
|
try MessageDeduplication(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId2",
|
|
expirationTimestampSeconds: nil,
|
|
shouldDeleteWhenDeletingThread: true
|
|
).insert(db)
|
|
}
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.deleteIfNeeded(
|
|
db,
|
|
threadIds: ["testThreadId"],
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
await expect(mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) })
|
|
.toEventually(beEmpty())
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId2")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- leaves unrelated records
|
|
it("leaves unrelated records") {
|
|
mockStorage.write { db in
|
|
try MessageDeduplication(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
expirationTimestampSeconds: nil,
|
|
shouldDeleteWhenDeletingThread: true
|
|
).insert(db)
|
|
try MessageDeduplication(
|
|
threadId: "testThreadId2",
|
|
uniqueIdentifier: "testId2",
|
|
expirationTimestampSeconds: nil,
|
|
shouldDeleteWhenDeletingThread: true
|
|
).insert(db)
|
|
}
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.deleteIfNeeded(
|
|
db,
|
|
threadIds: ["testThreadId"],
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [MessageDeduplication]? = await expect(mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) })
|
|
.toEventually(haveCount(1))
|
|
.retrieveValue()
|
|
expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId2"]))
|
|
expect((records?.map { $0.uniqueIdentifier }).map { Set($0) })
|
|
.to(equal(["testId2"]))
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- leaves records which should not be deleted alongside the thread
|
|
it("leaves records which should not be deleted alongside the thread") {
|
|
mockStorage.write { db in
|
|
try MessageDeduplication(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
expirationTimestampSeconds: nil,
|
|
shouldDeleteWhenDeletingThread: false
|
|
).insert(db)
|
|
}
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.deleteIfNeeded(
|
|
db,
|
|
threadIds: ["testThreadId"],
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [MessageDeduplication]? = mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) }
|
|
expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId"]))
|
|
expect((records?.map { $0.uniqueIdentifier }).map { Set($0) })
|
|
.to(equal(["testId"]))
|
|
expect(mockExtensionHelper).toNot(call {
|
|
try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any)
|
|
})
|
|
}
|
|
|
|
// MARK: ---- resets the expiration timestamp when failing to delete the file
|
|
it("resets the expiration timestamp when failing to delete the file") {
|
|
mockStorage.write { db in
|
|
try MessageDeduplication(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
expirationTimestampSeconds: 1234567890,
|
|
shouldDeleteWhenDeletingThread: true
|
|
).insert(db)
|
|
}
|
|
mockExtensionHelper
|
|
.when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenThrow(TestError.mock)
|
|
|
|
mockStorage.write { db in
|
|
expect {
|
|
try MessageDeduplication.deleteIfNeeded(
|
|
db,
|
|
threadIds: ["testThreadId"],
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
let records: [MessageDeduplication]? = await expect(mockStorage
|
|
.read { db in try MessageDeduplication.fetchAll(db) })
|
|
.toEventually(haveCount(1))
|
|
.retrieveValue()
|
|
expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId"]))
|
|
expect((records?.map { $0.uniqueIdentifier }).map { Set($0) })
|
|
.to(equal(["testId"]))
|
|
expect((records?.map { $0.expirationTimestampSeconds }).map { Set($0) })
|
|
.to(equal([0]))
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MessageDeduplication - Creating
|
|
describe("MessageDeduplication") {
|
|
// MARK: -- when creating a dedupe file
|
|
context("when creating a dedupe file") {
|
|
// MARK: ---- creates the file successfully
|
|
it("creates the file successfully") {
|
|
expect {
|
|
try MessageDeduplication.createDedupeFile(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- creates both the main file and a legacy file when needed
|
|
it("creates both the main file and a legacy file when needed") {
|
|
expect {
|
|
try MessageDeduplication.createDedupeFile(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testLegacyId"
|
|
)
|
|
})
|
|
}
|
|
|
|
// MARK: ---- creates a file from a ProcessedMessage
|
|
it("creates a file from a ProcessedMessage") {
|
|
expect {
|
|
try MessageDeduplication.createDedupeFile(
|
|
.standard(
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
proto: try! SNProtoContent.builder().build(),
|
|
messageInfo: MessageReceiveJob.Details.MessageInfo(
|
|
message: Message(),
|
|
variant: .visibleMessage,
|
|
threadVariant: .contact,
|
|
serverExpirationTimestamp: nil,
|
|
proto: try! SNProtoContent.builder().build()
|
|
),
|
|
uniqueIdentifier: "testId"
|
|
),
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- throws when it fails to create the file
|
|
it("throws when it fails to create the file") {
|
|
mockExtensionHelper
|
|
.when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenThrow(TestError.mock)
|
|
|
|
expect {
|
|
try MessageDeduplication.createDedupeFile(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(TestError.mock))
|
|
}
|
|
|
|
// MARK: ---- throws when it fails to create the legacy file
|
|
it("throws when it fails to create the legacy file") {
|
|
mockExtensionHelper
|
|
.when {
|
|
try $0.createDedupeRecord(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId"
|
|
)
|
|
}
|
|
.thenReturn(())
|
|
mockExtensionHelper
|
|
.when {
|
|
try $0.createDedupeRecord(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testLegacyId"
|
|
)
|
|
}
|
|
.thenThrow(TestError.mock)
|
|
|
|
expect {
|
|
try MessageDeduplication.createDedupeFile(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(TestError.mock))
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testLegacyId"
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// MARK: -- when creating a call message dedupe file
|
|
context("when creating a call message dedupe file") {
|
|
// MARK: ---- creates a preOffer file correctly
|
|
it("creates a preOffer file correctly") {
|
|
expect {
|
|
try MessageDeduplication.createCallDedupeFilesIfNeeded(
|
|
threadId: "testThreadId",
|
|
callMessage: CallMessage(
|
|
uuid: "12345",
|
|
kind: .preOffer,
|
|
sdps: [],
|
|
sentTimestampMs: 1234567890
|
|
),
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345-preOffer")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- creates a generic file correctly
|
|
it("creates a generic file correctly") {
|
|
expect {
|
|
try MessageDeduplication.createCallDedupeFilesIfNeeded(
|
|
threadId: "testThreadId",
|
|
callMessage: CallMessage(
|
|
uuid: "12345",
|
|
kind: .endCall,
|
|
sdps: [],
|
|
sentTimestampMs: 1234567890
|
|
),
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "12345")
|
|
})
|
|
}
|
|
|
|
// MARK: ---- creates a files for the correct call message kinds
|
|
it("creates a files for the correct call message kinds") {
|
|
var resultIdentifiers: [String] = []
|
|
var resultKinds: [CallMessage.Kind] = []
|
|
|
|
CallMessage.Kind.allCases.forEach { kind in
|
|
mockExtensionHelper
|
|
.when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) }
|
|
.then { args in
|
|
guard let identifier: String = args[test: 1] as? String else { return }
|
|
|
|
resultIdentifiers.append(identifier)
|
|
resultKinds.append(kind)
|
|
}
|
|
.thenReturn(())
|
|
|
|
expect {
|
|
try MessageDeduplication.createCallDedupeFilesIfNeeded(
|
|
threadId: "testThreadId",
|
|
callMessage: CallMessage(
|
|
uuid: "12345",
|
|
kind: kind,
|
|
sdps: [],
|
|
sentTimestampMs: 1234567890
|
|
),
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
expect(resultIdentifiers).to(equal(["12345-preOffer", "12345"]))
|
|
expect(resultKinds).to(equal([.preOffer, .endCall]))
|
|
}
|
|
|
|
// MARK: ---- creates files for the correct call message states
|
|
it("creates files for the correct call message states") {
|
|
var resultIdentifiers: [String] = []
|
|
var resultStates: [CallMessage.MessageInfo.State] = []
|
|
|
|
CallMessage.MessageInfo.State.allCases.forEach { state in
|
|
let message: CallMessage = CallMessage(
|
|
uuid: "12345",
|
|
kind: .answer,
|
|
sdps: [],
|
|
sentTimestampMs: 1234567890
|
|
)
|
|
message.state = state
|
|
mockExtensionHelper
|
|
.when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) }
|
|
.then { args in
|
|
guard let identifier: String = args[test: 1] as? String else { return }
|
|
|
|
resultIdentifiers.append(identifier)
|
|
resultStates.append(state)
|
|
}
|
|
.thenReturn(())
|
|
|
|
expect {
|
|
try MessageDeduplication.createCallDedupeFilesIfNeeded(
|
|
threadId: "testThreadId",
|
|
callMessage: message,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
expect(resultIdentifiers).to(equal(["12345", "12345", "12345"]))
|
|
expect(resultStates).to(equal([.missed, .permissionDenied, .permissionDeniedMicrophone]))
|
|
}
|
|
|
|
// MARK: ---- does nothing if no call message is provided
|
|
it("does nothing if no call message is provided") {
|
|
expect {
|
|
try MessageDeduplication.createCallDedupeFilesIfNeeded(
|
|
threadId: "testThreadId",
|
|
callMessage: nil,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
|
|
expect(mockExtensionHelper).toNot(call {
|
|
try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MessageDeduplication - Ensuring
|
|
describe("MessageDeduplication") {
|
|
// MARK: -- when ensuring a message is not a duplicate
|
|
context("when ensuring a message is not a duplicate") {
|
|
// MARK: ---- does not throw when not a duplicate
|
|
it("does not throw when not a duplicate") {
|
|
expect {
|
|
try MessageDeduplication.ensureMessageIsNotADuplicate(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
// MARK: ---- when ensuring a message is not a legacy duplicate
|
|
it("does not throw when not a legacy duplicate") {
|
|
expect {
|
|
try MessageDeduplication.ensureMessageIsNotADuplicate(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
// MARK: ---- does not throw when given a non duplicate ProcessedMessage
|
|
it("does not throw when given a non duplicate ProcessedMessage") {
|
|
expect {
|
|
try MessageDeduplication.ensureMessageIsNotADuplicate(
|
|
.standard(
|
|
threadId: "testThreadId",
|
|
threadVariant: .contact,
|
|
proto: try! SNProtoContent.builder().build(),
|
|
messageInfo: MessageReceiveJob.Details.MessageInfo(
|
|
message: Message(),
|
|
variant: .visibleMessage,
|
|
threadVariant: .contact,
|
|
serverExpirationTimestamp: nil,
|
|
proto: try! SNProtoContent.builder().build()
|
|
),
|
|
uniqueIdentifier: "testId"
|
|
),
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
// MARK: ---- throws when the message is a duplicate
|
|
it("throws when the message is a duplicate") {
|
|
mockExtensionHelper
|
|
.when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenReturn(true)
|
|
|
|
expect {
|
|
try MessageDeduplication.ensureMessageIsNotADuplicate(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(MessageReceiverError.duplicateMessage))
|
|
}
|
|
|
|
// MARK: ---- throws when the message is a legacy duplicate
|
|
it("throws when the message is a legacy duplicate") {
|
|
mockExtensionHelper
|
|
.when {
|
|
$0.dedupeRecordExists(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId"
|
|
)
|
|
}
|
|
.thenReturn(false)
|
|
mockExtensionHelper
|
|
.when {
|
|
$0.dedupeRecordExists(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testLegacyId"
|
|
)
|
|
}
|
|
.thenReturn(true)
|
|
|
|
expect {
|
|
try MessageDeduplication.ensureMessageIsNotADuplicate(
|
|
threadId: "testThreadId",
|
|
uniqueIdentifier: "testId",
|
|
legacyIdentifier: "testLegacyId",
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(MessageReceiverError.duplicateMessage))
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
$0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId")
|
|
})
|
|
expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) {
|
|
$0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId")
|
|
})
|
|
}
|
|
}
|
|
|
|
// MARK: -- when ensuring a call message is not a duplicate
|
|
context("when ensuring a call message is not a duplicate") {
|
|
// MARK: ---- does not throw when not a duplicate
|
|
it("does not throw when not a duplicate") {
|
|
expect {
|
|
try MessageDeduplication.ensureCallMessageIsNotADuplicate(
|
|
threadId: "testThreadId",
|
|
callMessage: CallMessage(
|
|
uuid: "12345",
|
|
kind: .preOffer,
|
|
sdps: [],
|
|
sentTimestampMs: nil
|
|
),
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
// MARK: ---- does nothing if no call message is provided
|
|
it("does nothing if no call message is provided") {
|
|
expect {
|
|
try MessageDeduplication.ensureCallMessageIsNotADuplicate(
|
|
threadId: "testThreadId",
|
|
callMessage: nil,
|
|
using: dependencies
|
|
)
|
|
}.toNot(throwError())
|
|
}
|
|
|
|
// MARK: ---- throws when the call message is a duplicate
|
|
it("throws when the call message is a duplicate") {
|
|
mockExtensionHelper
|
|
.when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) }
|
|
.thenReturn(true)
|
|
|
|
expect {
|
|
try MessageDeduplication.ensureCallMessageIsNotADuplicate(
|
|
threadId: "testThreadId",
|
|
callMessage: CallMessage(
|
|
uuid: "12345",
|
|
kind: .preOffer,
|
|
sdps: [],
|
|
sentTimestampMs: nil
|
|
),
|
|
using: dependencies
|
|
)
|
|
}.to(throwError(MessageReceiverError.duplicatedCall))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
extension CallMessage.Kind: @retroactive CaseIterable {
|
|
public static var allCases: [CallMessage.Kind] = {
|
|
var result: [CallMessage.Kind] = []
|
|
switch CallMessage.Kind.preOffer {
|
|
case .preOffer: result.append(.preOffer); fallthrough
|
|
case .offer: result.append(.offer); fallthrough
|
|
case .answer: result.append(.answer); fallthrough
|
|
case .provisionalAnswer: result.append(.provisionalAnswer); fallthrough
|
|
case .iceCandidates: result.append(.iceCandidates(sdpMLineIndexes: [], sdpMids: [])); fallthrough
|
|
case .endCall: result.append(.endCall)
|
|
}
|
|
|
|
return result
|
|
}()
|
|
}
|