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.
@@ -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,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Shared SQLModel schema and seed logic for resurf sites."""
3
+
4
+ __version__ = "0.1.0"
@@ -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