Files
clickstream-data-platform/generator/tests/test_orders.py
T
ddadminandClaude Opus 5 db632c2558 test(generator): анонимность события проверяется и текстом, не только числом
- Зачем:
  - холодное ревью Кодексом (линия дефектов) показало дыру в проверке
    анонимности: личность, уехавшая строкой в сыром `ecommerce`, проходила
    мимо сравнения числовых колонок, и тест оставался зелёным. Дыра
    воспроизведена: user_id, дописанный в блок покупки, тест не замечал.
- Что:
  - тест анонимности спрашивает двумя способами: числовые колонки дня — со
    всеми личностями аудитории, строки покупок целиком — с личностями
    покупателей. Сырой блок собирается руками, и лишнее поле в нём теперь
    красит проверку.
- Проверка:
  - из `generator/`: make lint, make typecheck, make test (417 тестов).
  - мутация «дописать user_id в ecommerce покупки»: до правки тест зелёный,
    после — красный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:46:16 +03:00

215 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке.
Заказ здесь ещё без судьбы — статуса, моментов и дельты (тикет #91): всё,
что проверяется, это согласие двух источников по построению. Поэтому и
сверяется заказ не с внутренней структурой торговой половины (это было бы
сверкой кода с самим собой), а с событием, которое уехало в трекер.
Чистота заказов от зерна и дня сторожится там же, где чистота событий, —
слепком дня в `test_day.py`: заказы день отдаёт наружу наравне с потоком.
"""
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, orders, plan, schema, world
from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
@pytest.fixture(autouse=True)
def fresh_memo():
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
plan.cohort.cache_clear()
@pytest.fixture(scope="module")
def weekday() -> day.Day:
return day.stream(CANONICAL_SEED, WEEKDAY)
@pytest.fixture(scope="module")
def week() -> list[day.Day]:
"""Неделя мира: назначенные пары покупают в разные дни."""
return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)]
def purchases_of(events: day.Day) -> dict[str, NDArray[Any]]:
"""Строки покупок дня — колонками, в порядке потока."""
here = events.columns["EventType"] == commerce.PURCHASE
return {name: value[here] for name, value in events.columns.items()}
def user_id_by_order(events: day.Day) -> dict[str, int]:
return dict(
zip(events.orders.order_id, events.orders.user_id.tolist(), strict=True)
)
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
"""Заказ — вторая проекция покупки, а не второе порождение.
У всякого заказа ровно одно событие `purchase`, и номер у них один:
сойтись двум источникам больше негде — сверка стенда соединяется
именно по нему.
"""
events = purchases_of(weekday)
numbers = [cell[0] for cell in events["purchaseID"]]
assert len(numbers) > 100
assert list(weekday.orders.order_id) == numbers
def test_the_order_repeats_the_basket_and_the_money_of_its_purchase(weekday: day.Day):
"""Корзина и деньги клиента у заказа те же — он их не пересчитывает."""
goods = catalog.catalog()
events = purchases_of(weekday)
for number, order in enumerate(weekday.orders.order_id):
assert order == events["purchaseID"][number][0]
items = weekday.orders.product[number]
assert [goods.sku[item] for item in items.tolist()] == (
events["productID"][number].tolist()
)
assert weekday.orders.quantity[number].tolist() == (
events["productQuantity"][number].tolist()
)
# Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках.
assert weekday.orders.items_total[number] == round(
events["purchaseRevenue"][number][0] * commerce.KOPECKS
)
def test_the_discount_comes_from_the_coupon_of_the_event(weekday: day.Day):
"""Промокод в событии обязан обернуться скидкой — иначе данные соврут."""
percent = dict(world.COUPONS)
events = purchases_of(weekday)
codes = [cell[0] for cell in events["purchaseCoupon"]]
assert sum(1 for code in codes if code) > 10
for number, code in enumerate(codes):
total = int(weekday.orders.items_total[number])
expected = total * percent[code] // 100 if code else 0
assert weekday.orders.discount[number] == expected
# Скидка без кода не берётся ниоткуда, а с кодом не съедает заказ.
assert np.all(weekday.orders.discount < weekday.orders.items_total)
def test_the_money_of_an_order_adds_up(weekday: day.Day):
"""`total` = `items_total` `discount` + `delivery`, целыми копейками."""
money = weekday.orders
assert np.array_equal(
money.total, money.items_total - money.discount + money.delivery
)
for column in (money.items_total, money.discount, money.delivery, money.total):
assert np.issubdtype(column.dtype, np.integer)
assert np.all(column >= 0)
prices = {price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS}
assert set(money.delivery.tolist()) == prices
# Доставка — деньги, которых нет ни в одном событии: без неё «считаем по
# бэкенду» ничего не значило бы.
assert np.any(money.total != money.items_total - money.discount)
def test_the_order_side_draws_from_its_own_named_branch(
monkeypatch: pytest.MonkeyPatch,
):
"""Заказная сторона берёт свой подпоток и ветвится по дню рождения заказа.
Адрес в дереве зерна и есть контракт компонента (спека генератора,
раздел 2): правка заказной механики не двигает ни трафик, ни торговлю,
а слепок читает судьбу заказа из дня, в который заказ родился.
"""
asked: list[tuple[int, int, Component]] = []
honest = orders.day_stream
def spy(seed: int, day_number: int, component: Component):
asked.append((seed, day_number, component))
return honest(seed, day_number, component)
monkeypatch.setattr(orders, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.ORDERS)]
def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
"""Личность заказу даёт план, а не разбор трекера."""
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
person = dict(
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
)
events = purchases_of(weekday)
for number, cookie in enumerate(events["ClientID"].tolist()):
assert weekday.orders.user_id[number] == person[cookie]
assert np.all(weekday.orders.user_id > 0)
assert weekday.orders.user_id.max() < 2**53
def test_both_cookies_of_a_pair_order_as_one_user(week: list[day.Day]):
"""Мост к склейке: заказы с двух кук пары несут один `user_id`.
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
остальные ждут своего дня за горизонтом.
"""
users: list[dict[int, set[int]]] = []
for events in week:
by_order = user_id_by_order(events)
rows = purchases_of(events)
seen: dict[int, set[int]] = {}
for cookie, number in zip(
rows["ClientID"].tolist(),
(cell[0] for cell in rows["purchaseID"]),
strict=True,
):
seen.setdefault(cookie, set()).add(by_order[number])
users.append(seen)
pairs = 0
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
if max(days) >= WEEK:
continue
pairs += 1
first, second = (
users[day_number][int(cohort.client_id[cookie])]
for cookie, day_number in zip(cookies, days, strict=True)
)
assert len(first | second) == 1
assert pairs > 10
def test_the_user_id_never_reaches_the_metrica_event(weekday: day.Day):
"""Кликстрим анонимен: личность держится формой контракта, а не забывчивостью.
Спрашивается двумя способами, потому что утечь личность может двумя.
Числом — тогда её видно в числовых колонках дня, и они сверяются со
всеми личностями дневной аудитории. Текстом — тогда сравнение чисел
её прозевало бы, поэтому строки покупок читаются целиком, вместе с
сырым `ecommerce`: этот блок собирается руками, и дописать в него
лишнее поле проще всего. Имени для личности в контракте схемы нет.
"""
people = set(plan.audience(CANONICAL_SEED, WEEKDAY).person_id.tolist())
assert people
names = {column.name.lower() for column in schema.COLUMNS}
assert not (names & {"userid", "user_id", "personid", "person_id"})
for name, value in weekday.columns.items():
if np.issubdtype(value.dtype, np.integer):
assert not (set(value.tolist()) & people), name
events = purchases_of(weekday)
text = "\n".join(
" ".join(str(value[row]) for value in events.values())
for row in range(events["WatchID"].size)
)
for person in weekday.orders.user_id.tolist():
assert str(person) not in text
assert set(weekday.orders.user_id.tolist()) <= people