mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:05:19 +00:00
Make private media deletion transactional
This commit is contained in:
@@ -1194,6 +1194,697 @@ struct ChatViewModelBluetoothTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Media Deletion Tests
|
||||
|
||||
struct ChatViewModelPrivateMediaDeletionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: String(repeating: "8", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "e", count: 32))"
|
||||
let outgoingID = "media-\(String(repeating: "f", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "incoming.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: outgoingID,
|
||||
sender: viewModel.nickname,
|
||||
senderPeerID: transport.myPeerID,
|
||||
recipient: "Peer",
|
||||
filename: "outgoing.jpg"
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: outgoingID)
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID])
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: incomingID)
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||
)
|
||||
#expect(
|
||||
transport.deletedPrivateMediaRelativePaths
|
||||
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||
)
|
||||
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func stableDeleteProtectsPathSharedWithLegacyBubble() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.persistDeletedPrivateMediaResult = false
|
||||
let peerID = PeerID(str: String(repeating: "6", count: 64))
|
||||
let stableID = "media-\(String(repeating: "5", count: 32))"
|
||||
let legacyID = UUID().uuidString
|
||||
let filename = "shared-migration.jpg"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: stableID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: filename
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: legacyID,
|
||||
sender: "Old client",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: filename
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: stableID)
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [[stableID]]
|
||||
)
|
||||
#expect(transport.deletedPrivateMediaRelativePaths == [[:]])
|
||||
#expect(
|
||||
transport.protectedPrivateMediaRelativePaths == [[
|
||||
"images/incoming/\(filename)"
|
||||
]]
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== [stableID, legacyID]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyIncomingDeleteLeavesAmbiguousPayloadForQuota() throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||
let incoming = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
.appendingPathComponent(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: incoming,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let payload = incoming.appendingPathComponent(
|
||||
"legacy-delete-\(UUID().uuidString).jpg"
|
||||
)
|
||||
try Data([0x01]).write(to: payload)
|
||||
defer { try? FileManager.default.removeItem(at: payload) }
|
||||
let legacyID = UUID().uuidString
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: legacyID,
|
||||
sender: "Old client",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: payload.lastPathComponent
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: legacyID)
|
||||
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: String(repeating: "1", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "a", count: 32))"
|
||||
let outgoingID = "media-\(String(repeating: "b", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "incoming.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: outgoingID,
|
||||
sender: viewModel.nickname,
|
||||
senderPeerID: transport.myPeerID,
|
||||
recipient: "Peer",
|
||||
filename: "outgoing.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "ordinary-message",
|
||||
sender: "Peer",
|
||||
content: "hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
], for: peerID)
|
||||
viewModel.registerTransfer(
|
||||
transferId: "outgoing-clear-transfer",
|
||||
messageID: outgoingID
|
||||
)
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||
)
|
||||
#expect(
|
||||
transport.deletedPrivateMediaRelativePaths
|
||||
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||
)
|
||||
#expect(
|
||||
transport.cancelledTransfers == ["outgoing-clear-transfer"]
|
||||
)
|
||||
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.persistDeletedPrivateMediaResult = false
|
||||
let peerID = PeerID(str: String(repeating: "2", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "c", count: 32))"
|
||||
let outgoingID = "media-\(String(repeating: "7", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "incoming.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: outgoingID,
|
||||
sender: viewModel.nickname,
|
||||
senderPeerID: transport.myPeerID,
|
||||
recipient: "Peer",
|
||||
filename: "outgoing.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "ordinary-message",
|
||||
sender: "Peer",
|
||||
content: "keep me on failure",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
], for: peerID)
|
||||
viewModel.registerTransfer(
|
||||
transferId: "failed-clear-outgoing",
|
||||
messageID: outgoingID
|
||||
)
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== [incomingID, outgoingID, "ordinary-message"]
|
||||
)
|
||||
#expect(transport.cancelledTransfers == ["failed-clear-outgoing"])
|
||||
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearFailurePreservesSameNameIncomingPayload() throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.persistDeletedPrivateMediaResult = false
|
||||
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "8", count: 32))"
|
||||
let outgoingID = "media-\(String(repeating: "9", count: 32))"
|
||||
let filename = "clear-collision-\(UUID().uuidString).jpg"
|
||||
let filesDirectory = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
.appendingPathComponent("files/images", isDirectory: true)
|
||||
let incomingDirectory = filesDirectory.appendingPathComponent(
|
||||
"incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
let outgoingDirectory = filesDirectory.appendingPathComponent(
|
||||
"outgoing",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: incomingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outgoingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let incomingURL = incomingDirectory.appendingPathComponent(filename)
|
||||
let outgoingURL = outgoingDirectory.appendingPathComponent(filename)
|
||||
try Data("incoming".utf8).write(to: incomingURL, options: .atomic)
|
||||
try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: incomingURL)
|
||||
try? FileManager.default.removeItem(at: outgoingURL)
|
||||
}
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: filename
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: outgoingID,
|
||||
sender: viewModel.nickname,
|
||||
senderPeerID: transport.myPeerID,
|
||||
recipient: "Peer",
|
||||
filename: filename
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: incomingURL.path))
|
||||
#expect(FileManager.default.fileExists(atPath: outgoingURL.path))
|
||||
#expect(
|
||||
transport.deletedPrivateMediaRelativePaths
|
||||
== [[incomingID: "images/incoming/\(filename)"]]
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== [incomingID, outgoingID]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatPreservesArrivalDuringTombstoneIO() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.deferDeletedPrivateMediaPersistence = true
|
||||
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "d", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "incoming.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "captured-text",
|
||||
sender: "Peer",
|
||||
content: "old",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
let arrival = BitchatMessage(
|
||||
id: "concurrent-arrival",
|
||||
sender: "Peer",
|
||||
content: "new",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(arrival, to: peerID))
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== ["concurrent-arrival"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
let burstID = Data(
|
||||
repeating: 0xE1,
|
||||
count: VoiceBurstPacket.burstIDSize
|
||||
)
|
||||
let start = try #require(VoiceBurstPacket(
|
||||
burstID: burstID,
|
||||
seq: 0,
|
||||
kind: .start(codec: .aacLC16kMono)
|
||||
))
|
||||
let cancel = try #require(VoiceBurstPacket(
|
||||
burstID: burstID,
|
||||
seq: 1,
|
||||
kind: .canceled
|
||||
))
|
||||
let coordinator = viewModel.liveVoiceCoordinator
|
||||
defer {
|
||||
coordinator.handleVoiceFramePayload(
|
||||
from: peerID,
|
||||
payload: cancel.encode(),
|
||||
timestamp: Date()
|
||||
)
|
||||
}
|
||||
coordinator.handleVoiceFramePayload(
|
||||
from: peerID,
|
||||
payload: start.encode(),
|
||||
timestamp: Date()
|
||||
)
|
||||
let liveMessage = try #require(
|
||||
viewModel.privateChats[peerID]?.first
|
||||
)
|
||||
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||
let ordinary = BitchatMessage(
|
||||
id: "clear-around-live-voice",
|
||||
sender: "Peer",
|
||||
content: "old text",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(ordinary, to: peerID))
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== [liveMessage.id]
|
||||
)
|
||||
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func overlappingClearsTombstoneTheLastMirroredStableAlias() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.deferDeletedPrivateMediaPersistence = true
|
||||
let firstPeerID = PeerID(str: String(repeating: "b", count: 64))
|
||||
let secondPeerID = PeerID(str: String(repeating: "c", count: 64))
|
||||
let sharedID = "media-\(String(repeating: "1", count: 32))"
|
||||
let firstUniqueID = "media-\(String(repeating: "2", count: 32))"
|
||||
let secondUniqueID = "media-\(String(repeating: "3", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: sharedID,
|
||||
sender: "Peer",
|
||||
senderPeerID: firstPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "shared.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: firstUniqueID,
|
||||
sender: "Peer",
|
||||
senderPeerID: firstPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "first.jpg"
|
||||
)
|
||||
], for: firstPeerID)
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: sharedID,
|
||||
sender: "Peer",
|
||||
senderPeerID: secondPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "shared.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: secondUniqueID,
|
||||
sender: "Peer",
|
||||
senderPeerID: secondPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "second.jpg"
|
||||
)
|
||||
], for: secondPeerID)
|
||||
|
||||
viewModel.clearPrivateChat(firstPeerID)
|
||||
viewModel.clearPrivateChat(secondPeerID)
|
||||
let queuedArrival = BitchatMessage(
|
||||
id: "arrival-after-queued-clear",
|
||||
sender: "Peer",
|
||||
content: "new",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: secondPeerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(
|
||||
queuedArrival,
|
||||
to: secondPeerID
|
||||
))
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches
|
||||
== [[firstUniqueID]]
|
||||
)
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [
|
||||
[firstUniqueID],
|
||||
[secondUniqueID, sharedID].sorted()
|
||||
]
|
||||
)
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty)
|
||||
#expect(
|
||||
viewModel.privateChats[secondPeerID]?.map(\.id)
|
||||
== ["arrival-after-queued-clear"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearFollowsCapturedRowsAcrossPeerIdentityMigration() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.deferDeletedPrivateMediaPersistence = true
|
||||
let sourcePeerID = PeerID(str: String(repeating: "d", count: 64))
|
||||
let destinationPeerID = PeerID(str: String(repeating: "e", count: 64))
|
||||
let thirdPeerID = PeerID(str: String(repeating: "f", count: 64))
|
||||
let stableID = "media-\(String(repeating: "4", count: 32))"
|
||||
viewModel.selectedPrivateChatPeer = sourcePeerID
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: stableID,
|
||||
sender: "Peer",
|
||||
senderPeerID: sourcePeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "migrated.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "captured-before-migration",
|
||||
sender: "Peer",
|
||||
content: "old",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: sourcePeerID
|
||||
)
|
||||
], for: sourcePeerID)
|
||||
|
||||
viewModel.clearPrivateChat(sourcePeerID)
|
||||
viewModel.migratePrivateChat(
|
||||
from: sourcePeerID,
|
||||
to: destinationPeerID
|
||||
)
|
||||
viewModel.selectedPrivateChatPeer = thirdPeerID
|
||||
let arrival = BitchatMessage(
|
||||
id: "arrival-after-migration",
|
||||
sender: "Peer",
|
||||
content: "new",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: destinationPeerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(
|
||||
arrival,
|
||||
to: destinationPeerID
|
||||
))
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect(
|
||||
viewModel.privateChats[destinationPeerID]?.map(\.id)
|
||||
== ["arrival-after-migration"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearFollowsMigrationWhenOldSourceIsRecreated() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.deferDeletedPrivateMediaPersistence = true
|
||||
let sourcePeerID = PeerID(str: String(repeating: "1", count: 64))
|
||||
let destinationPeerID = PeerID(str: String(repeating: "2", count: 64))
|
||||
let stableID = "media-\(String(repeating: "6", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: stableID,
|
||||
sender: "Peer",
|
||||
senderPeerID: sourcePeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "migrated-recreated.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "captured-before-recreation",
|
||||
sender: "Peer",
|
||||
content: "old",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: sourcePeerID
|
||||
)
|
||||
], for: sourcePeerID)
|
||||
|
||||
viewModel.clearPrivateChat(sourcePeerID)
|
||||
viewModel.migratePrivateChat(
|
||||
from: sourcePeerID,
|
||||
to: destinationPeerID
|
||||
)
|
||||
let recreatedArrival = BitchatMessage(
|
||||
id: "arrival-recreating-source",
|
||||
sender: "Peer",
|
||||
content: "new",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: sourcePeerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(
|
||||
recreatedArrival,
|
||||
to: sourcePeerID
|
||||
))
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect(
|
||||
(viewModel.privateChats[destinationPeerID] ?? []).isEmpty
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateChats[sourcePeerID]?.map(\.id)
|
||||
== ["arrival-recreating-source"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatKeepsMediaReferencedByAnotherConversation() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
|
||||
let aliasPeerID = PeerID(str: String(repeating: "5", count: 64))
|
||||
let messageID = "media-\(String(repeating: "6", count: 32))"
|
||||
let message = privateMediaMessage(
|
||||
id: messageID,
|
||||
sender: "Peer",
|
||||
senderPeerID: firstPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "mirrored.jpg"
|
||||
)
|
||||
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||
|
||||
viewModel.clearPrivateChat(firstPeerID)
|
||||
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect(viewModel.privateChats[firstPeerID]?.isEmpty == true)
|
||||
#expect(
|
||||
viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup()
|
||||
throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let firstPeerID = PeerID(str: String(repeating: "9", count: 64))
|
||||
let aliasPeerID = PeerID(str: String(repeating: "a", count: 64))
|
||||
let incomingDirectory = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
.appendingPathComponent("files/images/incoming", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: incomingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let fileURL = incomingDirectory.appendingPathComponent(
|
||||
"legacy-clear-\(UUID().uuidString).jpg"
|
||||
)
|
||||
try Data("legacy-image".utf8).write(to: fileURL, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let message = privateMediaMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: "Old client",
|
||||
senderPeerID: firstPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: fileURL.lastPathComponent
|
||||
)
|
||||
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||
|
||||
viewModel.clearPrivateChat(firstPeerID)
|
||||
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
#expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id])
|
||||
|
||||
viewModel.clearPrivateChat(aliasPeerID)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
|
||||
}
|
||||
|
||||
private func privateMediaMessage(
|
||||
id: String,
|
||||
sender: String,
|
||||
senderPeerID: PeerID,
|
||||
recipient: String,
|
||||
filename: String
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: sender,
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(filename)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: recipient,
|
||||
senderPeerID: senderPeerID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panic Clear Tests
|
||||
|
||||
struct ChatViewModelPanicTests {
|
||||
|
||||
Reference in New Issue
Block a user