Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -26,40 +26,7 @@ import DashSDKFFI
// serves every other read live. That isolation is the point: a load-path
// regression cannot pass because the wallet or unspent-TXO fetch failed
// first, and the reconcile regression cannot pass because the whole store
// was unreadable.

/// Serves every read live except the one model type it is told to fault,
/// and records the reads it saw so a test can prove which fetch failed.
private final class FetchFaultInjector: ModelFetching, @unchecked Sendable {
struct ReadFault: Error {}

private let live = LiveModelFetcher()
private let faulted: ObjectIdentifier
private let lock = NSLock()
private var reads: [String] = []

init(faulting model: any PersistentModel.Type) {
faulted = ObjectIdentifier(model)
}

/// Model names in the order they were read, the faulted one included.
var observedReads: [String] {
lock.lock()
defer { lock.unlock() }
return reads
}

func fetch<T: PersistentModel>(
_ descriptor: FetchDescriptor<T>,
in context: ModelContext
) throws -> [T] {
lock.lock()
reads.append(String(describing: T.self))
lock.unlock()
guard ObjectIdentifier(T.self) != faulted else { throw ReadFault() }
return try live.fetch(descriptor, in: context)
}
}
// was unreadable. The seam double is the shared `FetchFaultInjector`.

final class AssetLockSpendVisibilityTests: XCTestCase {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import XCTest
import SwiftData
@testable import SwiftDashSDK

/// Proving ground for the bulk `IN`-style fetches the wallet-changeset
/// round cache relies on (`PlatformWalletPersistenceHandler`'s
/// prefetch pass).
///
/// SwiftData translates `[Data].contains($0.column)` into a SQL
/// `IN (?, ?, …)` — but nothing else in this package exercised that
/// form before the round cache, and the sibling `Set.contains` form
/// famously does NOT translate (it throws at predicate-compile time).
/// These tests pin the exact contract the cache builder depends on:
///
/// 1. an `[Data]`-captured `contains` predicate round-trips BLOB keys
/// through the store, in chunks below SQLite's bind-variable limit;
/// 2. rows staged (unsaved) in the same context remain visible to the
/// bulk fetch (`includePendingChanges` default), which is what lets
/// the prefetch see rows earlier per-kind callbacks inserted in the
/// same begin/end changeset round.
@MainActor
final class BulkFetchPredicateTests: XCTestCase {

func testChunkedDataContainsPredicateFetchesAllSavedRows() throws {
let container = try DashModelContainer.createInMemory()
let context = ModelContext(container)

// More rows than one SQLite bind chunk (900) so the chunked
// fetch path is genuinely exercised.
let total = 2_000
var outpoints: [Data] = []
outpoints.reserveCapacity(total)
for i in 0..<total {
let tx = PersistentTransaction(txid: makeTxid(i), transactionData: Data())
context.insert(tx)
let txo = PersistentTxo(transaction: tx, vout: 0, amount: UInt64(i), address: "addr\(i)")
context.insert(txo)
outpoints.append(txo.outpoint)
}
try context.save()

var fetched: [Data: PersistentTxo] = [:]
for chunk in stride(from: 0, to: outpoints.count, by: 900).map({

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Test re-implements the chunking instead of exercising chunked(_:size:)

Because PlatformWalletPersistenceHandler.chunked is private, this test hand-rolls the same stride/slice logic inline — so it validates a copy of the algorithm, not the shipped helper, and the two can drift (say, a future chunk-size or slicing change) without this pin noticing. Widening chunked to internal (the suite already imports @testable) and calling it here would make the contract test bind to the real code; this PR's new FFIFixtures.swift shows the pattern of promoting shared test plumbing when a second user appears.


🤖 Posted autonomously by Claude on behalf of pasta.

Array(outpoints[$0..<min($0 + 900, outpoints.count)])
}) {
let descriptor = FetchDescriptor<PersistentTxo>(
predicate: #Predicate { chunk.contains($0.outpoint) }
)
for row in try context.fetch(descriptor) {
fetched[row.outpoint] = row
}
}

XCTAssertEqual(fetched.count, total)
for outpoint in outpoints {
XCTAssertNotNil(fetched[outpoint])
}
// Spot-check a payload survived the BLOB round trip.
XCTAssertEqual(fetched[outpoints[1234]]?.amount, 1234)
}

func testDataContainsPredicateSeesUnsavedPendingRows() throws {
let container = try DashModelContainer.createInMemory()
let context = ModelContext(container)

// One durably saved row, one staged-only row — the bulk fetch
// must see both, exactly like a mid-round prefetch that runs
// after earlier callbacks staged inserts without saving.
let savedTx = PersistentTransaction(txid: makeTxid(1), transactionData: Data())
context.insert(savedTx)
try context.save()

let pendingTx = PersistentTransaction(txid: makeTxid(2), transactionData: Data())
context.insert(pendingTx)

let txids = [makeTxid(1), makeTxid(2), makeTxid(3)]
let descriptor = FetchDescriptor<PersistentTransaction>(
predicate: #Predicate { txids.contains($0.txid) }
)
let rows = try context.fetch(descriptor)

XCTAssertEqual(Set(rows.map(\.txid)), [makeTxid(1), makeTxid(2)])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -450,8 +450,8 @@ final class DashPayContactPersistenceTests: XCTestCase {
let labelPtr = labelRaw.bindMemory(to: UInt8.self).baseAddress

var outgoing = ContactRequestFFI()
outgoing.owner_id = Self.tuple32(ownerId)
outgoing.contact_id = Self.tuple32(contactId)
outgoing.owner_id = tuple32(ownerId)
outgoing.contact_id = tuple32(contactId)
outgoing.is_outgoing = true
outgoing.sender_key_index = 5
outgoing.recipient_key_index = 6
Expand Down Expand Up @@ -534,8 +534,8 @@ final class DashPayContactPersistenceTests: XCTestCase {
}
_ = beginFn(callbacks.context, wid)
var ignore = ContactIgnoredSenderFFI()
ignore.owner_id = Self.tuple32(ownerId)
ignore.sender_id = Self.tuple32(contactId)
ignore.owner_id = tuple32(ownerId)
ignore.sender_id = tuple32(contactId)
ignore.is_ignored = true
withUnsafePointer(to: &ignore) { ignPtr in
let rc = contactsFn(
Expand Down Expand Up @@ -700,17 +700,6 @@ final class DashPayContactPersistenceTests: XCTestCase {
XCTAssertEqual(try fetchContactRows().count, 0)
}

/// Copy a 32-byte `Data` into the C fixed-array tuple shape the
/// FFI structs use for ids.
private static func tuple32(_ data: Data) -> FFIByteTuple32 {
precondition(data.count == 32)
var tuple: FFIByteTuple32 = (
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
)
withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) }
return tuple
}
}

// MARK: - DashPay payment-history persistence
Expand Down Expand Up @@ -1080,16 +1069,6 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase {

private let counterpartyId = Data((0..<32).map { UInt8($0 + 1) })

private static func tuple32(_ data: Data) -> FFIByteTuple32 {
precondition(data.count == 32)
var tuple: FFIByteTuple32 = (
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
)
withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) }
return tuple
}

func testInitFromFFICopiesAllFields() throws {
let txidCString = strdup("ab12cd34")
let memoCString = strdup("coffee ☕")
Expand All @@ -1099,7 +1078,7 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase {
}

var ffi = DashpayPaymentFFI()
ffi.counterparty_id = Self.tuple32(counterpartyId)
ffi.counterparty_id = tuple32(counterpartyId)
ffi.amount_duffs = 123_456_789
ffi.direction = DashPayPaymentDirection.received.rawValue
ffi.status = DashPayPaymentStatus.confirmed.rawValue
Expand All @@ -1122,7 +1101,7 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase {
defer { free(txidCString) }

var ffi = DashpayPaymentFFI()
ffi.counterparty_id = Self.tuple32(counterpartyId)
ffi.counterparty_id = tuple32(counterpartyId)
ffi.amount_duffs = 1
ffi.direction = DashPayPaymentDirection.sent.rawValue
ffi.status = DashPayPaymentStatus.pending.rawValue
Expand All @@ -1141,7 +1120,7 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase {
/// trapping.
func testUnknownDiscriminantsAndNullTxidDegradeGracefully() throws {
var ffi = DashpayPaymentFFI()
ffi.counterparty_id = Self.tuple32(counterpartyId)
ffi.counterparty_id = tuple32(counterpartyId)
ffi.amount_duffs = 42
ffi.direction = 99
ffi.status = 99
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation
@testable import SwiftDashSDK

// Shared fixtures for suites that hand-build the C structs the
// persistence handler consumes. Both conversions below were previously
// re-declared privately in every such suite; they are pure value
// transforms with no test-local state, so one copy serves all of them.

/// Copy a 32-byte `Data` into the C fixed-array tuple shape the FFI
/// structs use for txids, wallet ids, and identity ids.
func tuple32(_ data: Data) -> FFIByteTuple32 {
precondition(data.count == 32)
var tuple: FFIByteTuple32 = (
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
)
withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) }
return tuple
}

/// Deterministic 32-byte txid for index `i`: the little-endian `UInt64`
/// in the leading bytes keeps ids readable in failure output and lets a
/// test recover `i` back out of a stored key (see the outpoint decode in
/// `WalletChangesetRoundTests`).
func makeTxid(_ i: Int) -> Data {
var txid = Data(count: 32)
withUnsafeBytes(of: UInt64(i).littleEndian) { txid.replaceSubrange(0..<8, with: $0) }
return txid
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation
import SwiftData
@testable import SwiftDashSDK

/// `ModelFetching` seam double: serves every read live except the one
/// model type it is told to fault (none by default), and records the
/// reads it saw so a test can prove which fetch failed — or, with no
/// fault, count how many reads a code path issued.
final class FetchFaultInjector: ModelFetching, @unchecked Sendable {
struct ReadFault: Error {}

private let live = LiveModelFetcher()
private let faulted: ObjectIdentifier?
private let lock = NSLock()
private var reads: [String] = []

init(faulting model: (any PersistentModel.Type)? = nil) {
faulted = model.map { ObjectIdentifier($0) }
}

/// Model names in the order they were read, the faulted one included.
var observedReads: [String] {
lock.lock()
defer { lock.unlock() }
return reads
}

func fetch<T: PersistentModel>(
_ descriptor: FetchDescriptor<T>,
in context: ModelContext
) throws -> [T] {
lock.lock()
reads.append(String(describing: T.self))
lock.unlock()
guard ObjectIdentifier(T.self) != faulted else { throw ReadFault() }
return try live.fetch(descriptor, in: context)
}
}
Loading
Loading