mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 19:29:57 +02:00
Closes the loop: until now nothing in the UI could create a contact, so the list stayed empty unless the peer initiated. A completed receive names its sender and a completed delivery names its receiver, so both sides get the suggestion. Declining is persisted, or every later transfer with the same device would re-ask a question already answered; pairing deliberately afterwards clears that. The suggestion sheet ranks below the two other prompts, since nobody is waiting on the answer.
410 lines
12 KiB
Swift
410 lines
12 KiB
Swift
import XCTest
|
|
@testable import VniDrop
|
|
|
|
@MainActor
|
|
final class ContactsModelTests: XCTestCase {
|
|
private func makeModel(
|
|
_ gateway: FakeCoreGateway
|
|
) -> (ContactsModel, AppPreferencesRepository) {
|
|
let defaults = UserDefaults(suiteName: "contacts-tests-\(UUID().uuidString)")!
|
|
let preferences = AppPreferencesRepository(
|
|
defaults: defaults,
|
|
fallback: AppPreferencesDefaults(
|
|
username: "tester",
|
|
receiveFolder: ReceiveFolder(
|
|
kind: .fileSystemPath,
|
|
value: "/tmp",
|
|
displayName: "Downloads"
|
|
),
|
|
themeMode: .system
|
|
)
|
|
)
|
|
let model = ContactsModel(
|
|
repository: gateway,
|
|
messages: UiMessageController(),
|
|
preferences: preferences
|
|
)
|
|
return (model, preferences)
|
|
}
|
|
|
|
private func contact(
|
|
_ endpointId: String,
|
|
label: String? = nil,
|
|
remoteName: String? = nil,
|
|
canSend: Bool = true
|
|
) -> DeviceContact {
|
|
DeviceContact(
|
|
endpointId: endpointId,
|
|
localLabel: label,
|
|
remoteDisplayName: remoteName,
|
|
lastTransferAt: nil,
|
|
createdAt: 0,
|
|
canSend: canSend
|
|
)
|
|
}
|
|
|
|
private func offer(_ offerId: String, from endpointId: String = "peer") -> IncomingOfferModel {
|
|
IncomingOfferModel(
|
|
offerId: offerId,
|
|
fromEndpointId: endpointId,
|
|
senderDisplayName: "Peer",
|
|
transferName: "photos",
|
|
fileCount: 2,
|
|
totalBytes: 1_024,
|
|
receivedAt: 0
|
|
)
|
|
}
|
|
|
|
func testRefreshLoadsContactsBlocksAndPrompts() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.contactsResult = .success([contact("a"), contact("b")])
|
|
gateway.blockedResult = .success(["blocked-one"])
|
|
gateway.pairings = [PendingPairingModel(endpointId: "c", displayName: "Laptop", receivedAt: 0)]
|
|
gateway.offers = [offer("offer-1")]
|
|
let (model, _) = makeModel(gateway)
|
|
|
|
await model.refresh()
|
|
|
|
XCTAssertEqual(model.state.contacts.count, 2)
|
|
XCTAssertEqual(model.state.blocked, ["blocked-one"])
|
|
XCTAssertEqual(model.state.currentPairing?.endpointId, "c")
|
|
XCTAssertEqual(model.state.currentOffer?.offerId, "offer-1")
|
|
XCTAssertFalse(model.state.isLoading)
|
|
}
|
|
|
|
/// Accepting an offer is the only path that yields a ticket; the caller needs
|
|
/// it to run the receive with its own destination.
|
|
func testAcceptingAnOfferReturnsTheTicket() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.offers = [offer("offer-1")]
|
|
gateway.offerTicket = "vnd1:abc"
|
|
let (model, _) = makeModel(gateway)
|
|
await model.refresh()
|
|
|
|
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: true)
|
|
|
|
XCTAssertEqual(ticket, "vnd1:abc")
|
|
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
|
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true])
|
|
}
|
|
|
|
func testDecliningAnOfferYieldsNoTicketAndClearsThePrompt() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.offers = [offer("offer-1")]
|
|
let (model, _) = makeModel(gateway)
|
|
await model.refresh()
|
|
|
|
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: false)
|
|
|
|
XCTAssertNil(ticket, "a declined offer must not hand over a capability")
|
|
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
|
}
|
|
|
|
/// Declining to be remembered must leave nothing behind for the peer.
|
|
func testDecliningPairingClearsThePromptWithoutAddingAContact() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: nil, receivedAt: 0)]
|
|
let (model, _) = makeModel(gateway)
|
|
await model.refresh()
|
|
|
|
await model.respondToPairing(endpointId: "peer", accepted: false)
|
|
|
|
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
|
XCTAssertTrue(model.state.contacts.isEmpty)
|
|
XCTAssertEqual(gateway.pairingResponses.map(\.accepted), [false])
|
|
}
|
|
|
|
func testAcceptingPairingAddsTheContact() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: "Laptop", receivedAt: 0)]
|
|
let (model, _) = makeModel(gateway)
|
|
await model.refresh()
|
|
gateway.contactsResult = .success([contact("peer", remoteName: "Laptop")])
|
|
|
|
await model.respondToPairing(endpointId: "peer", accepted: true)
|
|
|
|
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
|
XCTAssertEqual(model.state.contacts.map(\.endpointId), ["peer"])
|
|
}
|
|
|
|
func testForgettingClearsTheSelectionAndReloads() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.contactsResult = .success([contact("peer")])
|
|
let (model, _) = makeModel(gateway)
|
|
await model.refresh()
|
|
model.select("peer")
|
|
|
|
gateway.contactsResult = .success([])
|
|
await model.forget(endpointId: "peer")
|
|
|
|
XCTAssertEqual(gateway.forgottenContacts, ["peer"])
|
|
XCTAssertNil(model.state.selectedEndpointId)
|
|
XCTAssertTrue(model.state.contacts.isEmpty)
|
|
}
|
|
|
|
func testBlockingRemovesTheContactAndKeepsItListedAsBlocked() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.contactsResult = .success([contact("peer")])
|
|
let (model, _) = makeModel(gateway)
|
|
await model.refresh()
|
|
model.select("peer")
|
|
|
|
gateway.contactsResult = .success([])
|
|
gateway.blockedResult = .success(["peer"])
|
|
await model.block(endpointId: "peer")
|
|
|
|
XCTAssertEqual(gateway.blockedContactIds, ["peer"])
|
|
XCTAssertNil(model.state.selectedEndpointId)
|
|
XCTAssertEqual(model.state.blocked, ["peer"])
|
|
}
|
|
|
|
/// An empty label clears the override rather than storing whitespace, so the
|
|
/// row falls back to the name the device reports.
|
|
func testBlankLabelClearsTheLocalName() async {
|
|
let gateway = FakeCoreGateway()
|
|
let (model, _) = makeModel(gateway)
|
|
|
|
await model.setLabel(endpointId: "peer", label: " ")
|
|
|
|
XCTAssertEqual(gateway.contactLabels.count, 1)
|
|
XCTAssertNil(gateway.contactLabels[0].label)
|
|
}
|
|
|
|
func testLabelIsTrimmedBeforeStoring() async {
|
|
let gateway = FakeCoreGateway()
|
|
let (model, _) = makeModel(gateway)
|
|
|
|
await model.setLabel(endpointId: "peer", label: " Work Mac ")
|
|
|
|
XCTAssertEqual(gateway.contactLabels[0].label, "Work Mac")
|
|
}
|
|
|
|
/// The core holds the lifetime in memory only, so the stored preference is
|
|
/// the durable copy and both have to move together.
|
|
func testGrantLifetimeIsPersistedAndPushedToTheCore() async {
|
|
let gateway = FakeCoreGateway()
|
|
let (model, preferences) = makeModel(gateway)
|
|
|
|
model.setGrantLifetime(.days365)
|
|
await Task.yield()
|
|
|
|
XCTAssertEqual(model.state.grantLifetime, .days365)
|
|
XCTAssertEqual(preferences.preferences.grantLifetime, .days365)
|
|
XCTAssertEqual(gateway.grantLifetimes.last, .days365)
|
|
}
|
|
|
|
func testDefaultGrantLifetimeIsNinetyDays() {
|
|
let gateway = FakeCoreGateway()
|
|
let (model, _) = makeModel(gateway)
|
|
|
|
XCTAssertEqual(model.state.grantLifetime, .days90)
|
|
}
|
|
|
|
/// The local label wins over whatever the peer calls itself.
|
|
func testDisplayNamePrefersTheLocalLabel() {
|
|
let subject = contact("peer", label: "Work Mac", remoteName: "Totally Not Evil")
|
|
|
|
XCTAssertEqual(subject.displayName, "Work Mac")
|
|
}
|
|
|
|
func testDisplayNameFallsBackToTheReportedName() {
|
|
let subject = contact("peer", remoteName: "Laptop")
|
|
|
|
XCTAssertEqual(subject.displayName, "Laptop")
|
|
}
|
|
|
|
func testUnreachableContactIsSurfacedForRepairing() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
|
let (model, _) = makeModel(gateway)
|
|
|
|
await model.refresh()
|
|
|
|
XCTAssertEqual(model.state.contacts.first?.canSend, false)
|
|
}
|
|
}
|
|
|
|
// MARK: - Post-transfer suggestions
|
|
|
|
@MainActor
|
|
final class PairingSuggestionTests: XCTestCase {
|
|
private func makeModel(
|
|
_ gateway: FakeCoreGateway,
|
|
defaults: UserDefaults
|
|
) -> (ContactsModel, AppPreferencesRepository) {
|
|
let preferences = AppPreferencesRepository(
|
|
defaults: defaults,
|
|
fallback: AppPreferencesDefaults(
|
|
username: "tester",
|
|
receiveFolder: ReceiveFolder(
|
|
kind: .fileSystemPath,
|
|
value: "/tmp",
|
|
displayName: "Downloads"
|
|
),
|
|
themeMode: .system
|
|
)
|
|
)
|
|
let model = ContactsModel(
|
|
repository: gateway,
|
|
messages: UiMessageController(),
|
|
preferences: preferences
|
|
)
|
|
return (model, preferences)
|
|
}
|
|
|
|
private func newDefaults() -> UserDefaults {
|
|
UserDefaults(suiteName: "suggestion-tests-\(UUID().uuidString)")!
|
|
}
|
|
|
|
private func completedReceive(from peerId: String?) -> Transfer {
|
|
Transfer(
|
|
localId: "local-1",
|
|
transferId: 1,
|
|
direction: .receive,
|
|
status: .done,
|
|
peerId: peerId,
|
|
transferName: "photos",
|
|
contentHash: nil,
|
|
fileCount: 1,
|
|
totalSize: 10,
|
|
ticket: nil,
|
|
accessPolicy: .requireApproval,
|
|
createdAt: 0,
|
|
updatedAt: 0
|
|
)
|
|
}
|
|
|
|
private func state(with transfers: [Transfer]) -> CoreState {
|
|
var core = CoreState()
|
|
core.isInitialized = true
|
|
core.transfers = transfers
|
|
return core
|
|
}
|
|
|
|
func testCompletedReceiveSuggestsItsSender() async {
|
|
let gateway = FakeCoreGateway()
|
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
|
await model.refresh()
|
|
|
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
|
await Task.yield()
|
|
|
|
XCTAssertEqual(model.state.currentSuggestion?.endpointId, "sender-endpoint")
|
|
}
|
|
|
|
/// A transfer that never recorded a peer cannot be turned into a suggestion.
|
|
func testReceiveWithoutAPeerIsNotSuggested() async {
|
|
let gateway = FakeCoreGateway()
|
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
|
await model.refresh()
|
|
|
|
gateway.setState(state(with: [completedReceive(from: nil)]))
|
|
await Task.yield()
|
|
|
|
XCTAssertNil(model.state.currentSuggestion)
|
|
}
|
|
|
|
func testAlreadyRememberedDeviceIsNotSuggested() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.contactsResult = .success([
|
|
DeviceContact(
|
|
endpointId: "sender-endpoint",
|
|
localLabel: nil,
|
|
remoteDisplayName: nil,
|
|
lastTransferAt: nil,
|
|
createdAt: 0,
|
|
canSend: true
|
|
)
|
|
])
|
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
|
await model.refresh()
|
|
|
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
|
await Task.yield()
|
|
|
|
XCTAssertNil(model.state.currentSuggestion)
|
|
}
|
|
|
|
func testBlockedDeviceIsNotSuggested() async {
|
|
let gateway = FakeCoreGateway()
|
|
gateway.blockedResult = .success(["sender-endpoint"])
|
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
|
await model.refresh()
|
|
|
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
|
await Task.yield()
|
|
|
|
XCTAssertNil(model.state.currentSuggestion)
|
|
}
|
|
|
|
/// Declining has to stick, or every later transfer with the same device
|
|
/// re-asks the question the user already answered.
|
|
func testDecliningIsRememberedAcrossLaterTransfers() async {
|
|
let defaults = newDefaults()
|
|
let gateway = FakeCoreGateway()
|
|
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
|
await model.refresh()
|
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
|
await Task.yield()
|
|
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
|
|
|
model.declineSuggestion(suggestion!)
|
|
|
|
XCTAssertNil(model.state.currentSuggestion)
|
|
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
|
|
|
// A second transfer with the same device must stay silent.
|
|
gateway.setState(CoreState())
|
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
|
await Task.yield()
|
|
XCTAssertNil(model.state.currentSuggestion)
|
|
}
|
|
|
|
func testAcceptingASuggestionIssuesAGrantUnderTheLocalUsername() async {
|
|
let gateway = FakeCoreGateway()
|
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
|
await model.refresh()
|
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
|
await Task.yield()
|
|
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
|
|
|
await model.acceptSuggestion(suggestion!)
|
|
|
|
XCTAssertEqual(gateway.allowedDevices.map(\.endpointId), ["sender-endpoint"])
|
|
XCTAssertEqual(gateway.allowedDevices.first?.displayName, "tester")
|
|
XCTAssertNil(model.state.currentSuggestion)
|
|
}
|
|
|
|
/// Pairing deliberately after declining should work, so the decline is
|
|
/// cleared rather than blocking the device forever.
|
|
func testAcceptingClearsAnEarlierDecline() async {
|
|
let defaults = newDefaults()
|
|
let gateway = FakeCoreGateway()
|
|
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
|
let suggestion = PairingSuggestion(
|
|
endpointId: "sender-endpoint",
|
|
displayName: nil,
|
|
transferName: nil
|
|
)
|
|
model.declineSuggestion(suggestion)
|
|
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
|
|
|
await model.acceptSuggestion(suggestion)
|
|
|
|
XCTAssertFalse(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
|
}
|
|
|
|
func testTheSameDeviceIsOnlySuggestedOnce() async {
|
|
let gateway = FakeCoreGateway()
|
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
|
await model.refresh()
|
|
|
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
|
await Task.yield()
|
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
|
await Task.yield()
|
|
|
|
XCTAssertEqual(model.state.suggestions.count, 1)
|
|
}
|
|
}
|