Skip to content

Commit ef01ba9

Browse files
committed
test(swift-sdk): integration test framework against local dashmate devnet
1 parent 79ffa47 commit ef01ba9

11 files changed

Lines changed: 846 additions & 2 deletions

packages/swift-sdk/Package.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,20 @@ let package = Package(
2828
linkerSettings: [.linkedFramework("SystemConfiguration")]
2929
),
3030

31-
// Tests
31+
// Unit tests (offline, hermetic)
3232
.testTarget(
3333
name: "SwiftDashSDKTests",
3434
dependencies: ["SwiftDashSDK"],
3535
path: "SwiftTests/SwiftDashSDKTests"
36-
)
36+
),
37+
38+
// Integration tests against a local dashmate devnet.
39+
// Gated by env var `RUN_INTEGRATION_TESTS=1`
40+
.testTarget(
41+
name: "SwiftDashSDKIntegrationTests",
42+
dependencies: ["SwiftDashSDK"],
43+
path: "SwiftTests/SwiftDashSDKIntegrationTests"
44+
),
3745
],
3846
swiftLanguageModes: [.v6]
3947
)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import XCTest
2+
import SwiftData
3+
@testable import SwiftDashSDK
4+
5+
final class CoreSendIntegrationTests: IntegrationTestCase {
6+
private let fundingDash: Double = 0.5
7+
private var fundingDuffs: UInt64 {
8+
UInt64(fundingDash * 1e8)
9+
}
10+
11+
func testWalletToWalletViaSpv() async throws {
12+
try await env.ensureSPVStarted()
13+
let alice = try await env.makeTestWallet(name: "core-send-alice")
14+
let bob = try await env.makeTestWallet(name: "core-send-bob")
15+
16+
let aliceAddress = try alice.getCoreWallet().nextReceiveAddress()
17+
_ = try await env.fund(address: aliceAddress, dash: fundingDash)
18+
let bobAddress = try bob.getCoreWallet().nextReceiveAddress()
19+
_ = try await env.fund(address: bobAddress, dash: fundingDash)
20+
try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90)
21+
try await bob.waitForSpendable(exactly: fundingDuffs, timeout: 90)
22+
23+
let iterations = 5
24+
let amount: UInt64 = 100_000 // 0.001 DASH per hop
25+
26+
for i in 0 ..< iterations {
27+
let aliceSends = (i % 2 == 0)
28+
let sender = aliceSends ? alice: bob
29+
let receiver = aliceSends ? bob: alice
30+
31+
let receiverBalanceBefore = try receiver.getPlatformWallet().balance().spendable
32+
let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress()
33+
34+
_ = try sender.getCoreWallet().sendToAddresses(
35+
recipients: [(address: recipientAddress, amountDuffs: amount)]
36+
)
37+
38+
try await Wait.until(
39+
"receiver +\(amount) after iteration \(i)",
40+
timeout: 60,
41+
pollInterval: 0.01
42+
) {
43+
try receiver.getPlatformWallet().balance().spendable
44+
== receiverBalanceBefore + amount
45+
}
46+
}
47+
48+
let aliceFinal = try alice.getPlatformWallet().balance().spendable
49+
let bobFinal = try bob.getPlatformWallet().balance().spendable
50+
XCTAssertLessThanOrEqual(aliceFinal + bobFinal, 2 * fundingDuffs)
51+
52+
// Validate via the SwiftData
53+
let expectedTotalTxs = 2 + iterations
54+
let aliceWalletId = alice.getPlatformWallet().walletId
55+
let bobWalletId = bob.getPlatformWallet().walletId
56+
let container = env.modelContainer
57+
58+
try await MainActor.run {
59+
let context = ModelContext(container)
60+
let allTxCount = try context.fetchCount(FetchDescriptor<PersistentTransaction>())
61+
XCTAssertEqual(allTxCount, expectedTotalTxs)
62+
63+
let aliceTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
64+
predicate: #Predicate<PersistentTxo>{
65+
$0.walletId == aliceWalletId
66+
}
67+
))
68+
let bobTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
69+
predicate: #Predicate<PersistentTxo>{
70+
$0.walletId == bobWalletId
71+
}
72+
))
73+
74+
XCTAssertEqual(aliceTxoCount, 1 + iterations)
75+
XCTAssertEqual(bobTxoCount, 1 + iterations)
76+
}
77+
}
78+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import XCTest
2+
import SwiftData
3+
@testable import SwiftDashSDK
4+
5+
/// Verifies that the persisted wallet state survives an SPV stop/start
6+
/// cycle
7+
final class SpvRestartIntegrationTests: IntegrationTestCase {
8+
private let fundingDash: Double = 0.5
9+
private var fundingDuffs: UInt64 { UInt64(fundingDash * 1e8) }
10+
private let amount: UInt64 = 100_000
11+
12+
func testTxosSurviveSpvRestart() async throws {
13+
try await env.ensureSPVStarted()
14+
let alice = try await env.makeTestWallet(name: "spv-restart-alice")
15+
let bob = try await env.makeTestWallet(name: "spv-restart-bob")
16+
17+
let aliceAddress = try alice.getCoreWallet().nextReceiveAddress()
18+
_ = try await env.fund(address: aliceAddress, dash: fundingDash)
19+
20+
let bobAddress = try bob.getCoreWallet().nextReceiveAddress()
21+
_ = try await env.fund(address: bobAddress, dash: fundingDash)
22+
23+
try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90)
24+
try await bob.waitForSpendable(exactly: fundingDuffs, timeout: 90)
25+
26+
try await sendAndConfirm(from: alice, to: bob)
27+
try await env.restartSPV()
28+
try await sendAndConfirm(from: alice, to: bob)
29+
30+
let aliceWalletId = alice.getPlatformWallet().walletId
31+
let bobWalletId = bob.getPlatformWallet().walletId
32+
let container = env.modelContainer
33+
34+
try await MainActor.run {
35+
let context = ModelContext(container)
36+
37+
let aliceTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
38+
predicate: #Predicate<PersistentTxo> { $0.walletId == aliceWalletId }
39+
))
40+
let bobTxoCount = try context.fetchCount(FetchDescriptor<PersistentTxo>(
41+
predicate: #Predicate<PersistentTxo> { $0.walletId == bobWalletId }
42+
))
43+
44+
XCTAssertEqual(aliceTxoCount, 3)
45+
XCTAssertEqual(bobTxoCount, 3)
46+
}
47+
}
48+
49+
private func sendAndConfirm(
50+
from sender: TestWalletWrapper,
51+
to receiver: TestWalletWrapper
52+
) async throws {
53+
let receiverBalanceBefore = try receiver.getPlatformWallet().balance().spendable
54+
let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress()
55+
56+
_ = try sender.getCoreWallet().sendToAddresses(
57+
recipients: [(address: recipientAddress, amountDuffs: amount)]
58+
)
59+
60+
try await Wait.until(
61+
"receiver balance advances by \(amount)",
62+
timeout: 60,
63+
pollInterval: 0.01
64+
) {
65+
try receiver.getPlatformWallet().balance().spendable
66+
== receiverBalanceBefore + amount
67+
}
68+
}
69+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import XCTest
2+
@testable import SwiftDashSDK
3+
4+
/// Placeholder until real Platform-side integration coverage lands
5+
final class SDKBootstrapIntegrationTests: IntegrationTestCase {
6+
func testSDKInitializes() {
7+
_ = env.sdk
8+
}
9+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import Foundation
2+
3+
/// Minimal JSON-RPC client for dashd, covering only the calls the
4+
/// integration framework needs.
5+
struct CoreRPCClient {
6+
private(set) var url: URL
7+
let username: String
8+
let password: String
9+
10+
init(port: Int, username: String, password: String) {
11+
var components = URLComponents()
12+
components.scheme = "http"
13+
components.host = "127.0.0.1"
14+
components.port = port
15+
guard let url = components.url else {
16+
preconditionFailure("Invalid Core RPC port: \(port)")
17+
}
18+
self.url = url
19+
self.username = username
20+
self.password = password
21+
}
22+
23+
// MARK: - Typed methods
24+
25+
/// Throws RPC error -32601 if dashd was built without the miner.
26+
@discardableResult
27+
func generateToAddress(count: Int, address: String) async throws -> [String] {
28+
try await call("generatetoaddress", params: [.int(count), .string(address)])
29+
}
30+
31+
func getNewAddress(walletName: String = "main") async throws -> String {
32+
try await wallet(walletName).call("getnewaddress")
33+
}
34+
35+
/// `amount` is in DASH (not duffs). Returns the txid.
36+
@discardableResult
37+
func sendToAddress(amount: Double, address: String, walletName: String = "main") async throws -> String {
38+
try await wallet(walletName).call(
39+
"sendtoaddress",
40+
params: [.string(address), .double(amount)]
41+
)
42+
}
43+
44+
/// Switches to the per-wallet RPC endpoint (`/wallet/<name>`).
45+
func wallet(_ name: String) -> CoreRPCClient {
46+
var copy = self
47+
copy.url = url.appendingPathComponent("wallet").appendingPathComponent(name)
48+
return copy
49+
}
50+
51+
// MARK: - Generic JSON-RPC
52+
53+
enum Param: Sendable {
54+
case string(String)
55+
case int(Int)
56+
case double(Double)
57+
}
58+
59+
enum RPCError: Error, CustomStringConvertible {
60+
case http(Int)
61+
case rpc(code: Int, message: String)
62+
case decode(String)
63+
64+
var description: String {
65+
switch self {
66+
case let .http(code): return "HTTP \(code)"
67+
case let .rpc(code, msg): return "RPC error \(code): \(msg)"
68+
case let .decode(msg): return "Decode error: \(msg)"
69+
}
70+
}
71+
}
72+
73+
func call<T: Decodable>(_ method: String, params: [Param] = []) async throws -> T {
74+
var request = URLRequest(url: url)
75+
request.httpMethod = "POST"
76+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
77+
78+
let credentials = "\(username):\(password)".data(using: .utf8)!.base64EncodedString()
79+
request.setValue("Basic \(credentials)", forHTTPHeaderField: "Authorization")
80+
81+
let body: [String: Any] = [
82+
"jsonrpc": "1.0",
83+
"id": "swift-integration",
84+
"method": method,
85+
"params": encodeParams(params),
86+
]
87+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
88+
89+
let (data, response) = try await URLSession.shared.data(for: request)
90+
// dashd returns the RPC error in the body even on 500; only
91+
// bail on HTTP errors if we can't parse the JSON-RPC body.
92+
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
93+
if let decoded = try? decodeRPCResponse(data, T.self) {
94+
return try unwrap(decoded)
95+
}
96+
throw RPCError.http(http.statusCode)
97+
}
98+
let decoded = try decodeRPCResponse(data, T.self)
99+
return try unwrap(decoded)
100+
}
101+
102+
private func decodeRPCResponse<T: Decodable>(_ data: Data, _: T.Type) throws -> RPCResponse<T> {
103+
do {
104+
return try JSONDecoder().decode(RPCResponse<T>.self, from: data)
105+
} catch {
106+
let raw = String(data: data, encoding: .utf8) ?? "<non-utf8>"
107+
throw RPCError.decode("\(error) — body: \(raw)")
108+
}
109+
}
110+
111+
private func unwrap<T: Decodable>(_ response: RPCResponse<T>) throws -> T {
112+
if let error = response.error {
113+
throw RPCError.rpc(code: error.code, message: error.message)
114+
}
115+
guard let result = response.result else {
116+
throw RPCError.decode("RPC response had neither `result` nor `error`")
117+
}
118+
return result
119+
}
120+
121+
private func encodeParams(_ params: [Param]) -> [Any] {
122+
params.map { param -> Any in
123+
switch param {
124+
case let .string(s): return s
125+
case let .int(i): return i
126+
case let .double(d): return d
127+
}
128+
}
129+
}
130+
131+
private struct RPCResponse<T: Decodable>: Decodable {
132+
let result: T?
133+
let error: RPCErrorBody?
134+
}
135+
136+
private struct RPCErrorBody: Decodable {
137+
let code: Int
138+
let message: String
139+
}
140+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
import XCTest
3+
4+
open class IntegrationTestCase: XCTestCase {
5+
private(set) var env: IntegrationTestEnv!
6+
7+
// XCTest serialises class-level setUps; only one path touches
8+
// this latch.
9+
nonisolated(unsafe) private static var bootstrapResult: Result<IntegrationTestEnv, Error>?
10+
11+
open override func setUp() async throws {
12+
try await super.setUp()
13+
try skipIfDisabled()
14+
env = try await Self.sharedEnv()
15+
}
16+
17+
open override func tearDown() async throws {
18+
env?.purgeStoredMnemonics()
19+
try await super.tearDown()
20+
}
21+
22+
private func skipIfDisabled() throws {
23+
let enabled = ProcessInfo.processInfo.environment["RUN_INTEGRATION_TESTS"] == "1"
24+
try XCTSkipUnless(
25+
enabled,
26+
"Integration tests skipped — set RUN_INTEGRATION_TESTS=1 to enable"
27+
)
28+
}
29+
30+
private static func sharedEnv() async throws -> IntegrationTestEnv {
31+
if let cached = bootstrapResult {
32+
return try cached.get()
33+
}
34+
do {
35+
let env = try await IntegrationTestEnv.bootstrap()
36+
bootstrapResult = .success(env)
37+
return env
38+
} catch {
39+
bootstrapResult = .failure(error)
40+
throw error
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)