tests/checkout_test.oxl
use super.model { Money, EmptyCart, UserId }
use super.checkout { Shop, Inventory, Payments, ReserveResp, ChargeResp }
use super.checkout { OutOfStockError, DeclinedError }
use super.orders { Orders, Order, OrderEvent }
use super.config { App }
// Тесты V.5 — как в Стандарте; склад и платежи подменены `mock`. Остальные тесты саги ходят в фейковые gRPC-серверы
// (tests/shop/fakes, адрес — APP_INVENTORY_ADDR или профиль dev).
import proto "fake_admin.proto"
// Фикстура: значение до yield получает тест, код после yield — очистка
fixture customer() -> UserId { yield UserId(42) }
// Юнит-тест: узел с ролью all в памяти, сервисы и акторы — в процессе
test "checkout empty cart" with customer as uid {
asUser(uid) { assert_fails(Shop.checkout(), EmptyCart) } // вызов от имени пользователя
}
test "payment timeout is retried by saga" with customer as uid {
// Моки внешних сервисов: ответы по порядку вызовов
mock Inventory { reserve(_) => ReserveResp{ reservationId: "r1" } always }
mock Payments { charge(_) => fail Timeout once, then ChargeResp{ receiptId: "p1" } }
asUser(uid) {
Shop.addToCart(TEST_ITEM)! // ОТКЛОНЕНИЕ (DEVIATIONS.md #7): `?` внутри блока asUser вернул бы ошибку из блока; `!` проваливает тест
assert Shop.checkout() is Order
}
// Первый charge упал по таймауту, второй прошёл
assert Payments.calls(.charge).count == 2
// Оба вызова пришли с одним ключом идемпотентности — деньги не спишутся дважды
assert Payments.calls(.charge).all(c => c.ctx.idempotencyKey == Payments.calls(.charge)[0].ctx.idempotencyKey)
}
// Симуляция: сеть, часы и планировщик детерминированы, тест прогоняется для 300 seed.
// Узлы симуляции (D-6252): по узлу на роль кластера (api-0, storage-0, saga-0, saga-1, gpu-0, ml-0), тест выполняется на api-0;
// сагу исполняет saga-0 или saga-1, после падения saga-0 её подхватывает saga-1 (журнал саги общий, D-6256).
// ОТКЛОНЕНИЕ (DEVIATIONS.md #7): `node.kill("saga-0") after 50ms` записан как `after: 50ms` (D-4109).
@tag(slow)
test sim "checkout survives saga node crash" seeds 1..=300 with customer as uid {
mock Inventory { reserve(_) => ReserveResp{ reservationId: "r1" } always; release(_) => Ok always }
mock Payments { charge(_) => ChargeResp{ receiptId: "p1" } always; refund(_) => Ok always }
std.test.net.latency(5ms..200ms) // случайные задержки сети (зависят от seed)
std.test.node.kill("saga-0", after: 50ms) // роняем узел саги посреди оформления
let r = asUser(uid) { await (Shop.addToCart(TEST_ITEM) -> Shop.checkout()) }
assert r is Order | Timeout
assert r is Order || Inventory.calls(.release).count >= 1 // либо заказ, либо резерв снят
}
// Интеграционный тест: настоящий кластер из 3 узлов (D-6110: `oxl test` запускает 3 процесса-узла, тест — на узле n0)
@tag(slow, integ)
test integ "order events reach subscriber" with cluster(nodes: 3), customer as uid {
let sub = asUser(uid) { Orders.watch() } // подписываемся до оформления
asUser(uid) { await (Shop.addToCart(TEST_ITEM) -> Shop.checkout()) }? // ОТКЛОНЕНИЕ (#7): цепочку ждём явно (await)
assert (await sub.first()) is OrderEvent.Created // первое событие — создание заказа
}
test "checkout creates an order and clears the cart" {
asUser(UserId(43)) {
Shop.addToCart(testItem("BOOK-1", 2))!
Shop.addToCart(testItem("PEN-7", 3))!
let o = Shop.checkout()!
assert o.items.len() == 2
assert o.total == Money.rub(50)
assert o.userId.raw == 43
}
}
// Сага placeOrder (V.5): склад отвечает «нет в наличии» на первом шаге — ничего не списано, корзина осталась
test "checkout of an out-of-stock cart returns OutOfStock" {
asUser(UserId(44)) {
Shop.addToCart(testItem("OUT-1", 1))!
assert_fails(Shop.checkout(), OutOfStockError)
}
}
// Платёж отклонён на втором шаге: резерв снят компенсацией (undo), заказ не создан. Счётчики фейка общие для всех прогонов,
// поэтому сравниваем «до» и «после»: прибавился ровно один резерв и ровно одно снятие резерва, платежей не прибавилось.
test "declined payment releases the reservation" {
let admin = Admin.connect(App.get().paymentsAddr, tls: ClientTls{})
let before = admin.totals()!
asUser(UserId(666)) {
Shop.addToCart(testItem("BOOK-1", 1))!
assert_fails(Shop.checkout(), DeclinedError)
}
let after = admin.totals()!
assert counter(before, "reserves") + 1 == counter(after, "reserves")
assert counter(before, "releases") + 1 == counter(after, "releases")
assert counter(before, "charges") == counter(after, "charges")
}
// значение счётчика из строки вида «reserves=3 releases=1 …»
fn counter(totals: str, name: str) -> i64 {
for part in totals.split(" ") {
if part.startsWith("{name}=") {
return match part.trimPrefix("{name}=").parseInt() {
i64 n => n
ParseError _ => -1
}
}
}
-1
}Исходник: examples/shop/tests/checkout_test.oxl