resurf-models 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- resurf_models-0.1.0/.gitignore +76 -0
- resurf_models-0.1.0/PKG-INFO +28 -0
- resurf_models-0.1.0/README.md +10 -0
- resurf_models-0.1.0/pyproject.toml +30 -0
- resurf_models-0.1.0/resurf_models/__init__.py +4 -0
- resurf_models-0.1.0/resurf_models/shop_v1/__init__.py +38 -0
- resurf_models-0.1.0/resurf_models/shop_v1/models.py +179 -0
- resurf_models-0.1.0/resurf_models/shop_v1/seed.py +263 -0
- resurf_models-0.1.0/resurf_models/shop_v1/state.py +133 -0
- resurf_models-0.1.0/tests/test_seed.py +82 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg-info/
|
|
7
|
+
*.egg
|
|
8
|
+
.eggs/
|
|
9
|
+
.Python
|
|
10
|
+
build/
|
|
11
|
+
develop-eggs/
|
|
12
|
+
dist/
|
|
13
|
+
downloads/
|
|
14
|
+
eggs/
|
|
15
|
+
# Python setuptools build dirs — anchored so they don't sweep up
|
|
16
|
+
# legitimate nested "lib/" dirs (e.g. frontend/src/lib).
|
|
17
|
+
/lib/
|
|
18
|
+
/lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.mypy_cache/
|
|
25
|
+
.ruff_cache/
|
|
26
|
+
.coverage
|
|
27
|
+
htmlcov/
|
|
28
|
+
.tox/
|
|
29
|
+
.hypothesis/
|
|
30
|
+
|
|
31
|
+
# Virtual environments
|
|
32
|
+
.venv/
|
|
33
|
+
venv/
|
|
34
|
+
env/
|
|
35
|
+
ENV/
|
|
36
|
+
|
|
37
|
+
# Node
|
|
38
|
+
node_modules/
|
|
39
|
+
.pnpm-store/
|
|
40
|
+
npm-debug.log*
|
|
41
|
+
yarn-debug.log*
|
|
42
|
+
yarn-error.log*
|
|
43
|
+
pnpm-debug.log*
|
|
44
|
+
|
|
45
|
+
# Build outputs
|
|
46
|
+
dist/
|
|
47
|
+
build/
|
|
48
|
+
*.tsbuildinfo
|
|
49
|
+
.parcel-cache/
|
|
50
|
+
.next/
|
|
51
|
+
.vite/
|
|
52
|
+
.turbo/
|
|
53
|
+
|
|
54
|
+
# Editors
|
|
55
|
+
.vscode/
|
|
56
|
+
.idea/
|
|
57
|
+
*.swp
|
|
58
|
+
*.swo
|
|
59
|
+
.DS_Store
|
|
60
|
+
|
|
61
|
+
# Project-specific
|
|
62
|
+
trajectories/
|
|
63
|
+
*.sqlite
|
|
64
|
+
*.sqlite-shm
|
|
65
|
+
*.sqlite-wal
|
|
66
|
+
sites/shop_v1/seed/snapshots/*.sqlite
|
|
67
|
+
sites/shop_v1/backend/data/
|
|
68
|
+
|
|
69
|
+
# Env
|
|
70
|
+
.env
|
|
71
|
+
.env.local
|
|
72
|
+
.env.*.local
|
|
73
|
+
|
|
74
|
+
# Playwright
|
|
75
|
+
playwright-report/
|
|
76
|
+
test-results/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: resurf-models
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared SQLModel schema and seed logic for resurf sites
|
|
5
|
+
Author: The resurf contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: agents,ai,evaluation,resurf
|
|
8
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: faker>=24.0
|
|
13
|
+
Requires-Dist: pydantic>=2.6
|
|
14
|
+
Requires-Dist: sqlmodel>=0.0.16
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# resurf-models
|
|
20
|
+
|
|
21
|
+
Shared SQLModel schema and deterministic seed data for resurf sites.
|
|
22
|
+
|
|
23
|
+
This package is imported by both the site backend (FastAPI) and the resurf SDK so that:
|
|
24
|
+
- There is exactly one source of truth for the data schema
|
|
25
|
+
- `success_fn` predicates in tasks can read backend state via the same models the site writes
|
|
26
|
+
- Seeded fixtures are byte-for-byte reproducible across the SDK and the site
|
|
27
|
+
|
|
28
|
+
Each site has its own subpackage (e.g. `resurf_models.shop_v1`).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# resurf-models
|
|
2
|
+
|
|
3
|
+
Shared SQLModel schema and deterministic seed data for resurf sites.
|
|
4
|
+
|
|
5
|
+
This package is imported by both the site backend (FastAPI) and the resurf SDK so that:
|
|
6
|
+
- There is exactly one source of truth for the data schema
|
|
7
|
+
- `success_fn` predicates in tasks can read backend state via the same models the site writes
|
|
8
|
+
- Seeded fixtures are byte-for-byte reproducible across the SDK and the site
|
|
9
|
+
|
|
10
|
+
Each site has its own subpackage (e.g. `resurf_models.shop_v1`).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["hatchling"]
|
|
4
|
+
build-backend = "hatchling.build"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "resurf-models"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "Shared SQLModel schema and seed logic for resurf sites"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
license = { text = "Apache-2.0" }
|
|
13
|
+
authors = [{ name = "The resurf contributors" }]
|
|
14
|
+
keywords = ["resurf", "ai", "agents", "evaluation"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"sqlmodel>=0.0.16",
|
|
22
|
+
"pydantic>=2.6",
|
|
23
|
+
"faker>=24.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=8.0"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["resurf_models"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""shop_v1 schema and seed logic."""
|
|
3
|
+
|
|
4
|
+
from .models import (
|
|
5
|
+
Address,
|
|
6
|
+
Cart,
|
|
7
|
+
CartItem,
|
|
8
|
+
Category,
|
|
9
|
+
Coupon,
|
|
10
|
+
EventLog,
|
|
11
|
+
Order,
|
|
12
|
+
OrderItem,
|
|
13
|
+
PaymentAttempt,
|
|
14
|
+
Product,
|
|
15
|
+
Return,
|
|
16
|
+
Session,
|
|
17
|
+
User,
|
|
18
|
+
)
|
|
19
|
+
from .seed import seed_database
|
|
20
|
+
from .state import StateReader
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Address",
|
|
24
|
+
"Cart",
|
|
25
|
+
"CartItem",
|
|
26
|
+
"Category",
|
|
27
|
+
"Coupon",
|
|
28
|
+
"EventLog",
|
|
29
|
+
"Order",
|
|
30
|
+
"OrderItem",
|
|
31
|
+
"PaymentAttempt",
|
|
32
|
+
"Product",
|
|
33
|
+
"Return",
|
|
34
|
+
"Session",
|
|
35
|
+
"StateReader",
|
|
36
|
+
"User",
|
|
37
|
+
"seed_database",
|
|
38
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""SQLModel schema for shop_v1.
|
|
3
|
+
|
|
4
|
+
This module is the single source of truth for shop_v1's data shape. The site
|
|
5
|
+
backend writes to it; the resurf SDK reads from it for success_fn
|
|
6
|
+
predicates. Keep field semantics stable across versions where possible.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from sqlmodel import Field, SQLModel
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Catalog
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Category(SQLModel, table=True):
|
|
21
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
22
|
+
slug: str = Field(unique=True, index=True)
|
|
23
|
+
name: str
|
|
24
|
+
description: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Product(SQLModel, table=True):
|
|
28
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
29
|
+
slug: str = Field(unique=True, index=True)
|
|
30
|
+
name: str = Field(index=True)
|
|
31
|
+
description: str
|
|
32
|
+
short_description: str
|
|
33
|
+
price_cents: int
|
|
34
|
+
currency: str = "USD"
|
|
35
|
+
stock: int = 0
|
|
36
|
+
image_url: str
|
|
37
|
+
category_id: int = Field(foreign_key="category.id", index=True)
|
|
38
|
+
rating: float = 0.0
|
|
39
|
+
rating_count: int = 0
|
|
40
|
+
tags: str = "" # comma-separated for cheap filtering
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Users / sessions
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class User(SQLModel, table=True):
|
|
49
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
50
|
+
email: str = Field(unique=True, index=True)
|
|
51
|
+
password_hash: str
|
|
52
|
+
full_name: str
|
|
53
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Address(SQLModel, table=True):
|
|
57
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
58
|
+
user_id: int = Field(foreign_key="user.id", index=True)
|
|
59
|
+
label: str = "home"
|
|
60
|
+
full_name: str
|
|
61
|
+
line1: str
|
|
62
|
+
line2: str = ""
|
|
63
|
+
city: str
|
|
64
|
+
state: str
|
|
65
|
+
postal_code: str
|
|
66
|
+
country: str = "US"
|
|
67
|
+
is_default: bool = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Session(SQLModel, table=True):
|
|
71
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
72
|
+
token: str = Field(unique=True, index=True)
|
|
73
|
+
user_id: int | None = Field(default=None, foreign_key="user.id", index=True)
|
|
74
|
+
csrf_token: str
|
|
75
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
76
|
+
expires_at: datetime
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Cart
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Cart(SQLModel, table=True):
|
|
85
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
86
|
+
session_token: str = Field(unique=True, index=True)
|
|
87
|
+
user_id: int | None = Field(default=None, foreign_key="user.id", index=True)
|
|
88
|
+
coupon_code: str | None = None
|
|
89
|
+
cart_coupon_attempt: str | None = None # last code typed in *cart-side* input (does not apply)
|
|
90
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CartItem(SQLModel, table=True):
|
|
94
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
95
|
+
cart_id: int = Field(foreign_key="cart.id", index=True)
|
|
96
|
+
product_id: int = Field(foreign_key="product.id", index=True)
|
|
97
|
+
quantity: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Coupons
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Coupon(SQLModel, table=True):
|
|
106
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
107
|
+
code: str = Field(unique=True, index=True)
|
|
108
|
+
description: str
|
|
109
|
+
percent_off: int = 0 # 0-100
|
|
110
|
+
flat_off_cents: int = 0
|
|
111
|
+
min_subtotal_cents: int = 0
|
|
112
|
+
expires_at: datetime | None = None
|
|
113
|
+
active: bool = True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Orders
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Order(SQLModel, table=True):
|
|
122
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
123
|
+
user_id: int = Field(foreign_key="user.id", index=True)
|
|
124
|
+
shipping_address_id: int = Field(foreign_key="address.id")
|
|
125
|
+
coupon_code: str | None = None
|
|
126
|
+
subtotal_cents: int
|
|
127
|
+
discount_cents: int = 0
|
|
128
|
+
tax_cents: int = 0
|
|
129
|
+
shipping_cents: int = 0
|
|
130
|
+
total_cents: int
|
|
131
|
+
status: str = "pending" # pending | paid | failed | cancelled | refunded
|
|
132
|
+
payment_attempts: int = 0
|
|
133
|
+
last_payment_error: str | None = None
|
|
134
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
135
|
+
paid_at: datetime | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class OrderItem(SQLModel, table=True):
|
|
139
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
140
|
+
order_id: int = Field(foreign_key="order.id", index=True)
|
|
141
|
+
product_id: int = Field(foreign_key="product.id")
|
|
142
|
+
product_name: str # snapshot
|
|
143
|
+
unit_price_cents: int
|
|
144
|
+
quantity: int
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class PaymentAttempt(SQLModel, table=True):
|
|
148
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
149
|
+
order_id: int = Field(foreign_key="order.id", index=True)
|
|
150
|
+
card_last4: str
|
|
151
|
+
outcome: str # success | declined | timeout | 3ds_required
|
|
152
|
+
error_code: str | None = None
|
|
153
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Return(SQLModel, table=True):
|
|
157
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
158
|
+
order_id: int = Field(foreign_key="order.id", index=True)
|
|
159
|
+
user_id: int = Field(foreign_key="user.id", index=True)
|
|
160
|
+
reason: str
|
|
161
|
+
status: str = "requested" # requested | approved | rejected | refunded
|
|
162
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class EventLog(SQLModel, table=True):
|
|
166
|
+
"""Generic append-only event log so navigation/interaction tasks can be SQL-checkable.
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
type='product_view', detail='{"slug": "acme-bluetooth-speaker"}'
|
|
170
|
+
type='search', detail='{"q": "speaker"}'
|
|
171
|
+
type='cart_open', detail='{"path": "/cart"}'
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
175
|
+
session_token: str | None = Field(default=None, index=True)
|
|
176
|
+
user_id: int | None = Field(default=None, foreign_key="user.id", index=True)
|
|
177
|
+
type: str = Field(index=True)
|
|
178
|
+
detail: str = "" # JSON-encoded
|
|
179
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Deterministic seed data for shop_v1.
|
|
3
|
+
|
|
4
|
+
A given seed integer produces byte-for-byte identical data. This is the
|
|
5
|
+
foundation of reproducibility: tasks reference seeded objects by name/slug
|
|
6
|
+
and success_fn predicates run against a known initial state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import random
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from faker import Faker
|
|
16
|
+
from sqlmodel import Session as DBSession
|
|
17
|
+
from sqlmodel import SQLModel, create_engine, select
|
|
18
|
+
|
|
19
|
+
from .models import (
|
|
20
|
+
Address,
|
|
21
|
+
Category,
|
|
22
|
+
Coupon,
|
|
23
|
+
Product,
|
|
24
|
+
User,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Hand-curated category list so seeds are stable and tasks are easy to write.
|
|
28
|
+
CATEGORIES = [
|
|
29
|
+
("audio", "Audio", "Headphones, speakers, and earbuds."),
|
|
30
|
+
("wearables", "Wearables", "Smartwatches and fitness trackers."),
|
|
31
|
+
("home", "Home", "Smart home, lighting, and small appliances."),
|
|
32
|
+
("fragrance", "Fragrance", "Perfumes, colognes, and home scents."),
|
|
33
|
+
("apparel", "Apparel", "Tops, bottoms, and accessories."),
|
|
34
|
+
("outdoors", "Outdoors", "Bags, bottles, and gear."),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# A small set of hand-picked anchor products that tasks reference by name.
|
|
38
|
+
# These are guaranteed to exist in every seed and have predictable attributes.
|
|
39
|
+
ANCHOR_PRODUCTS = [
|
|
40
|
+
{
|
|
41
|
+
"slug": "acme-bluetooth-speaker",
|
|
42
|
+
"name": "Acme Bluetooth Speaker",
|
|
43
|
+
"category": "audio",
|
|
44
|
+
"price_cents": 7999,
|
|
45
|
+
"stock": 25,
|
|
46
|
+
"tags": "bluetooth,portable,wireless",
|
|
47
|
+
"rating": 4.5,
|
|
48
|
+
"rating_count": 312,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"slug": "acme-pro-headphones",
|
|
52
|
+
"name": "Acme Pro Headphones",
|
|
53
|
+
"category": "audio",
|
|
54
|
+
"price_cents": 24999,
|
|
55
|
+
"stock": 12,
|
|
56
|
+
"tags": "noise-cancelling,wireless,over-ear",
|
|
57
|
+
"rating": 4.7,
|
|
58
|
+
"rating_count": 856,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"slug": "lumen-smart-bulb",
|
|
62
|
+
"name": "Lumen Smart Bulb",
|
|
63
|
+
"category": "home",
|
|
64
|
+
"price_cents": 1499,
|
|
65
|
+
"stock": 0, # deliberately out of stock
|
|
66
|
+
"tags": "smart-home,led,wifi",
|
|
67
|
+
"rating": 4.2,
|
|
68
|
+
"rating_count": 198,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"slug": "trailmate-water-bottle",
|
|
72
|
+
"name": "TrailMate Water Bottle",
|
|
73
|
+
"category": "outdoors",
|
|
74
|
+
"price_cents": 2999,
|
|
75
|
+
"stock": 80,
|
|
76
|
+
"tags": "stainless,insulated,outdoor",
|
|
77
|
+
"rating": 4.6,
|
|
78
|
+
"rating_count": 421,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"slug": "northwood-pulse-watch",
|
|
82
|
+
"name": "Northwood Pulse Watch",
|
|
83
|
+
"category": "wearables",
|
|
84
|
+
"price_cents": 19999,
|
|
85
|
+
"stock": 8,
|
|
86
|
+
"tags": "smartwatch,fitness,heart-rate",
|
|
87
|
+
"rating": 4.4,
|
|
88
|
+
"rating_count": 267,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"slug": "saffron-no-7-eau-de-parfum",
|
|
92
|
+
"name": "Saffron No.7 Eau de Parfum",
|
|
93
|
+
"category": "fragrance",
|
|
94
|
+
"price_cents": 8999,
|
|
95
|
+
"stock": 30,
|
|
96
|
+
"tags": "unisex,floral,50ml",
|
|
97
|
+
"rating": 4.3,
|
|
98
|
+
"rating_count": 144,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"slug": "saffron-no-7-travel-spray",
|
|
102
|
+
"name": "Saffron No.7 Travel Spray",
|
|
103
|
+
"category": "fragrance",
|
|
104
|
+
"price_cents": 3499,
|
|
105
|
+
"stock": 50,
|
|
106
|
+
"tags": "unisex,floral,10ml",
|
|
107
|
+
"rating": 4.1,
|
|
108
|
+
"rating_count": 88,
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
# Coupons that tasks reference by name. SUMMER15 is the canonical example.
|
|
113
|
+
COUPONS = [
|
|
114
|
+
{
|
|
115
|
+
"code": "SUMMER15",
|
|
116
|
+
"description": "15% off your order",
|
|
117
|
+
"percent_off": 15,
|
|
118
|
+
"min_subtotal_cents": 0,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"code": "WELCOME10",
|
|
122
|
+
"description": "$10 off orders over $50",
|
|
123
|
+
"percent_off": 0,
|
|
124
|
+
"flat_off_cents": 1000,
|
|
125
|
+
"min_subtotal_cents": 5000,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"code": "FRAGRANCE20",
|
|
129
|
+
"description": "20% off fragrance",
|
|
130
|
+
"percent_off": 20,
|
|
131
|
+
"min_subtotal_cents": 0,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"code": "EXPIRED5",
|
|
135
|
+
"description": "Expired test coupon",
|
|
136
|
+
"percent_off": 5,
|
|
137
|
+
"expires_at": datetime(2020, 1, 1),
|
|
138
|
+
"active": False,
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _password_hash(plaintext: str) -> str:
|
|
144
|
+
# Trivial hash for synthetic data. The site backend uses argon2 for real
|
|
145
|
+
# password verification; this just gives us a stable hash in the seed file.
|
|
146
|
+
return "test$" + hashlib.sha256(plaintext.encode()).hexdigest()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def seed_database(database_url: str, seed: int = 42) -> None:
|
|
150
|
+
"""Wipe and reseed the database. Idempotent for a given seed integer."""
|
|
151
|
+
engine = create_engine(database_url)
|
|
152
|
+
SQLModel.metadata.drop_all(engine)
|
|
153
|
+
SQLModel.metadata.create_all(engine)
|
|
154
|
+
|
|
155
|
+
fake = Faker()
|
|
156
|
+
Faker.seed(seed)
|
|
157
|
+
rng = random.Random(seed)
|
|
158
|
+
|
|
159
|
+
with DBSession(engine) as db:
|
|
160
|
+
# Categories
|
|
161
|
+
cats: dict[str, Category] = {}
|
|
162
|
+
for slug, name, desc in CATEGORIES:
|
|
163
|
+
c = Category(slug=slug, name=name, description=desc)
|
|
164
|
+
db.add(c)
|
|
165
|
+
cats[slug] = c
|
|
166
|
+
db.flush()
|
|
167
|
+
|
|
168
|
+
# Anchor products
|
|
169
|
+
for ap in ANCHOR_PRODUCTS:
|
|
170
|
+
p = Product(
|
|
171
|
+
slug=ap["slug"],
|
|
172
|
+
name=ap["name"],
|
|
173
|
+
description=fake.paragraph(nb_sentences=4),
|
|
174
|
+
short_description=fake.sentence(nb_words=12),
|
|
175
|
+
price_cents=ap["price_cents"],
|
|
176
|
+
stock=ap["stock"],
|
|
177
|
+
image_url=f"/static/products/{ap['slug']}.jpg",
|
|
178
|
+
category_id=cats[ap["category"]].id, # type: ignore[arg-type]
|
|
179
|
+
rating=ap["rating"],
|
|
180
|
+
rating_count=ap["rating_count"],
|
|
181
|
+
tags=ap["tags"],
|
|
182
|
+
)
|
|
183
|
+
db.add(p)
|
|
184
|
+
|
|
185
|
+
# Filler products so the catalog is realistic-feeling, not 7 items.
|
|
186
|
+
for _ in range(60):
|
|
187
|
+
cat_slug = rng.choice(list(cats.keys()))
|
|
188
|
+
name = fake.unique.catch_phrase()[:48]
|
|
189
|
+
slug = name.lower().replace(" ", "-").replace("/", "-").replace(",", "")[:64]
|
|
190
|
+
db.add(
|
|
191
|
+
Product(
|
|
192
|
+
slug=slug,
|
|
193
|
+
name=name,
|
|
194
|
+
description=fake.paragraph(nb_sentences=3),
|
|
195
|
+
short_description=fake.sentence(nb_words=10),
|
|
196
|
+
price_cents=rng.randint(999, 49999),
|
|
197
|
+
stock=rng.randint(0, 100),
|
|
198
|
+
image_url=f"/static/products/placeholder-{rng.randint(1, 12)}.jpg",
|
|
199
|
+
category_id=cats[cat_slug].id, # type: ignore[arg-type]
|
|
200
|
+
rating=round(rng.uniform(3.0, 5.0), 1),
|
|
201
|
+
rating_count=rng.randint(0, 1500),
|
|
202
|
+
tags=",".join(fake.words(nb=3, unique=True)),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Coupons
|
|
207
|
+
for c in COUPONS:
|
|
208
|
+
db.add(Coupon(**c))
|
|
209
|
+
|
|
210
|
+
# The seeded test user
|
|
211
|
+
user = User(
|
|
212
|
+
email="alex@example.com",
|
|
213
|
+
password_hash=_password_hash("password123"),
|
|
214
|
+
full_name="Alex Doe",
|
|
215
|
+
created_at=datetime(2025, 1, 1),
|
|
216
|
+
)
|
|
217
|
+
db.add(user)
|
|
218
|
+
db.flush()
|
|
219
|
+
|
|
220
|
+
# Default address
|
|
221
|
+
db.add(
|
|
222
|
+
Address(
|
|
223
|
+
user_id=user.id, # type: ignore[arg-type]
|
|
224
|
+
label="home",
|
|
225
|
+
full_name="Alex Doe",
|
|
226
|
+
line1="100 Test Street",
|
|
227
|
+
city="San Francisco",
|
|
228
|
+
state="CA",
|
|
229
|
+
postal_code="94110",
|
|
230
|
+
country="US",
|
|
231
|
+
is_default=True,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
# A second address so "saved address" tasks have something to disambiguate
|
|
235
|
+
db.add(
|
|
236
|
+
Address(
|
|
237
|
+
user_id=user.id, # type: ignore[arg-type]
|
|
238
|
+
label="work",
|
|
239
|
+
full_name="Alex Doe",
|
|
240
|
+
line1="500 Market Street",
|
|
241
|
+
line2="Floor 14",
|
|
242
|
+
city="San Francisco",
|
|
243
|
+
state="CA",
|
|
244
|
+
postal_code="94105",
|
|
245
|
+
country="US",
|
|
246
|
+
is_default=False,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
db.commit()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def seeded_user_id(database_url: str) -> int:
|
|
254
|
+
"""Convenience helper: returns the id of the seeded test user."""
|
|
255
|
+
engine = create_engine(database_url)
|
|
256
|
+
with DBSession(engine) as db:
|
|
257
|
+
u = db.exec(select(User).where(User.email == "alex@example.com")).first()
|
|
258
|
+
if u is None or u.id is None:
|
|
259
|
+
raise RuntimeError("Seeded user not found; run seed_database first.")
|
|
260
|
+
return u.id
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
__all__ = ["ANCHOR_PRODUCTS", "CATEGORIES", "COUPONS", "seed_database", "seeded_user_id"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""State reader for shop_v1 success_fn predicates.
|
|
3
|
+
|
|
4
|
+
The resurf SDK uses StateReader to evaluate task success against the
|
|
5
|
+
backend SQLite file directly, bypassing the HTTP layer. This is what makes
|
|
6
|
+
deterministic eval possible: no flaky DOM scraping for "did the order go
|
|
7
|
+
through?", just a SQL query against shared models.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import text
|
|
15
|
+
from sqlmodel import Session as DBSession
|
|
16
|
+
from sqlmodel import create_engine, select
|
|
17
|
+
|
|
18
|
+
from .models import (
|
|
19
|
+
Address,
|
|
20
|
+
Cart,
|
|
21
|
+
CartItem,
|
|
22
|
+
Coupon,
|
|
23
|
+
Order,
|
|
24
|
+
OrderItem,
|
|
25
|
+
PaymentAttempt,
|
|
26
|
+
Product,
|
|
27
|
+
Return,
|
|
28
|
+
User,
|
|
29
|
+
)
|
|
30
|
+
from .seed import seeded_user_id
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StateReader:
|
|
34
|
+
"""Read-only view of the shop_v1 database for success predicates."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, database_url: str) -> None:
|
|
37
|
+
self.database_url = database_url
|
|
38
|
+
self._engine = create_engine(database_url)
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# Generic helpers used by YAML state_predicate tasks
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def query_scalar(self, sql: str, **bindings: Any) -> Any:
|
|
45
|
+
"""Run a raw SQL query and return the first column of the first row."""
|
|
46
|
+
with self._engine.connect() as conn:
|
|
47
|
+
row = conn.execute(text(sql), bindings).first()
|
|
48
|
+
if row is None:
|
|
49
|
+
return None
|
|
50
|
+
return row[0]
|
|
51
|
+
|
|
52
|
+
def query_rows(self, sql: str, **bindings: Any) -> list[dict[str, Any]]:
|
|
53
|
+
"""Run a raw SQL query and return all rows as dicts."""
|
|
54
|
+
with self._engine.connect() as conn:
|
|
55
|
+
result = conn.execute(text(sql), bindings)
|
|
56
|
+
return [dict(row._mapping) for row in result]
|
|
57
|
+
|
|
58
|
+
def default_bindings(self) -> dict[str, Any]:
|
|
59
|
+
"""Useful named parameters that tasks can reference (e.g. :seeded_user_id)."""
|
|
60
|
+
return {"seeded_user_id": seeded_user_id(self.database_url)}
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Typed accessors used by Python escape-hatch success_fn implementations
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def orders_for_seeded_user(self, status: str | None = None) -> list[Order]:
|
|
67
|
+
with DBSession(self._engine) as db:
|
|
68
|
+
stmt = select(Order).where(Order.user_id == seeded_user_id(self.database_url))
|
|
69
|
+
if status is not None:
|
|
70
|
+
stmt = stmt.where(Order.status == status)
|
|
71
|
+
return list(db.exec(stmt).all())
|
|
72
|
+
|
|
73
|
+
def latest_order_for_seeded_user(self) -> Order | None:
|
|
74
|
+
with DBSession(self._engine) as db:
|
|
75
|
+
stmt = (
|
|
76
|
+
select(Order)
|
|
77
|
+
.where(Order.user_id == seeded_user_id(self.database_url))
|
|
78
|
+
.order_by(Order.created_at.desc()) # type: ignore[attr-defined]
|
|
79
|
+
)
|
|
80
|
+
return db.exec(stmt).first()
|
|
81
|
+
|
|
82
|
+
def order_items(self, order_id: int) -> list[OrderItem]:
|
|
83
|
+
with DBSession(self._engine) as db:
|
|
84
|
+
return list(db.exec(select(OrderItem).where(OrderItem.order_id == order_id)).all())
|
|
85
|
+
|
|
86
|
+
def payment_attempts(self, order_id: int) -> list[PaymentAttempt]:
|
|
87
|
+
with DBSession(self._engine) as db:
|
|
88
|
+
return list(
|
|
89
|
+
db.exec(select(PaymentAttempt).where(PaymentAttempt.order_id == order_id)).all()
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def product_by_name(self, name: str) -> Product | None:
|
|
93
|
+
with DBSession(self._engine) as db:
|
|
94
|
+
return db.exec(select(Product).where(Product.name == name)).first()
|
|
95
|
+
|
|
96
|
+
def product_by_slug(self, slug: str) -> Product | None:
|
|
97
|
+
with DBSession(self._engine) as db:
|
|
98
|
+
return db.exec(select(Product).where(Product.slug == slug)).first()
|
|
99
|
+
|
|
100
|
+
def coupon(self, code: str) -> Coupon | None:
|
|
101
|
+
with DBSession(self._engine) as db:
|
|
102
|
+
return db.exec(select(Coupon).where(Coupon.code == code)).first()
|
|
103
|
+
|
|
104
|
+
def cart_for_session(self, session_token: str) -> Cart | None:
|
|
105
|
+
with DBSession(self._engine) as db:
|
|
106
|
+
return db.exec(select(Cart).where(Cart.session_token == session_token)).first()
|
|
107
|
+
|
|
108
|
+
def cart_items(self, cart_id: int) -> list[CartItem]:
|
|
109
|
+
with DBSession(self._engine) as db:
|
|
110
|
+
return list(db.exec(select(CartItem).where(CartItem.cart_id == cart_id)).all())
|
|
111
|
+
|
|
112
|
+
def returns_for_seeded_user(self) -> list[Return]:
|
|
113
|
+
with DBSession(self._engine) as db:
|
|
114
|
+
return list(
|
|
115
|
+
db.exec(
|
|
116
|
+
select(Return).where(Return.user_id == seeded_user_id(self.database_url))
|
|
117
|
+
).all()
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def addresses_for_seeded_user(self) -> list[Address]:
|
|
121
|
+
with DBSession(self._engine) as db:
|
|
122
|
+
return list(
|
|
123
|
+
db.exec(
|
|
124
|
+
select(Address).where(Address.user_id == seeded_user_id(self.database_url))
|
|
125
|
+
).all()
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def all_users(self) -> list[User]:
|
|
129
|
+
with DBSession(self._engine) as db:
|
|
130
|
+
return list(db.exec(select(User)).all())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = ["StateReader"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Tests for shop_v1 seed determinism and schema."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
from resurf_models.shop_v1 import StateReader, seed_database
|
|
10
|
+
from resurf_models.shop_v1.models import Coupon, Product, User
|
|
11
|
+
from sqlmodel import Session as DBSession
|
|
12
|
+
from sqlmodel import create_engine, select
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fresh_db() -> str:
|
|
16
|
+
fd, path = tempfile.mkstemp(suffix=".sqlite")
|
|
17
|
+
os.close(fd)
|
|
18
|
+
return f"sqlite:///{path}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_seed_creates_anchor_products():
|
|
22
|
+
url = _fresh_db()
|
|
23
|
+
seed_database(url, seed=42)
|
|
24
|
+
engine = create_engine(url)
|
|
25
|
+
with DBSession(engine) as db:
|
|
26
|
+
slugs = {p.slug for p in db.exec(select(Product)).all()}
|
|
27
|
+
assert "acme-bluetooth-speaker" in slugs
|
|
28
|
+
assert "lumen-smart-bulb" in slugs
|
|
29
|
+
assert "trailmate-water-bottle" in slugs
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_seed_is_deterministic():
|
|
33
|
+
url1 = _fresh_db()
|
|
34
|
+
url2 = _fresh_db()
|
|
35
|
+
seed_database(url1, seed=42)
|
|
36
|
+
seed_database(url2, seed=42)
|
|
37
|
+
e1 = create_engine(url1)
|
|
38
|
+
e2 = create_engine(url2)
|
|
39
|
+
with DBSession(e1) as db1, DBSession(e2) as db2:
|
|
40
|
+
prods1 = sorted(p.slug for p in db1.exec(select(Product)).all())
|
|
41
|
+
prods2 = sorted(p.slug for p in db2.exec(select(Product)).all())
|
|
42
|
+
assert prods1 == prods2
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_seeded_user_exists():
|
|
46
|
+
url = _fresh_db()
|
|
47
|
+
seed_database(url, seed=42)
|
|
48
|
+
engine = create_engine(url)
|
|
49
|
+
with DBSession(engine) as db:
|
|
50
|
+
u = db.exec(select(User).where(User.email == "alex@example.com")).first()
|
|
51
|
+
assert u is not None
|
|
52
|
+
assert u.full_name == "Alex Doe"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_state_reader_orders_for_user():
|
|
56
|
+
url = _fresh_db()
|
|
57
|
+
seed_database(url, seed=42)
|
|
58
|
+
sr = StateReader(url)
|
|
59
|
+
orders = sr.orders_for_seeded_user()
|
|
60
|
+
# Fresh seed => no orders yet
|
|
61
|
+
assert orders == []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_lumen_is_out_of_stock():
|
|
65
|
+
url = _fresh_db()
|
|
66
|
+
seed_database(url, seed=42)
|
|
67
|
+
engine = create_engine(url)
|
|
68
|
+
with DBSession(engine) as db:
|
|
69
|
+
p = db.exec(select(Product).where(Product.slug == "lumen-smart-bulb")).first()
|
|
70
|
+
assert p is not None
|
|
71
|
+
assert p.stock == 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_summer15_coupon_active():
|
|
75
|
+
url = _fresh_db()
|
|
76
|
+
seed_database(url, seed=42)
|
|
77
|
+
engine = create_engine(url)
|
|
78
|
+
with DBSession(engine) as db:
|
|
79
|
+
c = db.exec(select(Coupon).where(Coupon.code == "SUMMER15")).first()
|
|
80
|
+
assert c is not None
|
|
81
|
+
assert c.percent_off == 15
|
|
82
|
+
assert c.active is True
|