- Зачем:
- холодное ревью Кодексом (линия дефектов) показало дыру в проверке
анонимности: личность, уехавшая строкой в сыром `ecommerce`, проходила
мимо сравнения числовых колонок, и тест оставался зелёным. Дыра
воспроизведена: user_id, дописанный в блок покупки, тест не замечал.
- Что:
- тест анонимности спрашивает двумя способами: числовые колонки дня — со
всеми личностями аудитории, строки покупок целиком — с личностями
покупателей. Сырой блок собирается руками, и лишнее поле в нём теперь
красит проверку.
- Проверка:
- из `generator/`: make lint, make typecheck, make test (417 тестов).
- мутация «дописать user_id в ecommerce покупки»: до правки тест зелёный,
после — красный.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
10 KiB
Python
215 lines
10 KiB
Python
"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке.
|
||
|
||
Заказ здесь ещё без судьбы — статуса, моментов и дельты (тикет #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
|