OxL Reference

tests/checkout_test.oxl

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
}

Source: examples/shop/tests/checkout_test.oxl