MPS-20 + MPS-21: tick scripts — auction state transitions, offer expiration

Without these scripts, auctions stay ACTIVE forever and offers never
expire. Cron runs them on a schedule to drive the state machines.

lib/auction_tick.py:
- Pure functions transition_scheduled_to_active and transition_active_to_ended
- tick(dbsession) orchestrator scans:
  - SCHEDULED + start_timestamp <= now → ACTIVE
  - ACTIVE + end_timestamp <= now → ENDED (winner from is_winning bid;
    payment_deadline_timestamp set to end + 48h)

lib/offer_tick.py:
- tick(dbsession) scans non-terminal offers (PENDING / COUNTERED) past
  expires_timestamp and calls lib/offer.expire_offer on each, which
  flips state to EXPIRED and writes the OFFER_EVENT_EXPIRE audit row

Both ticks are idempotent — running twice on already-transitioned rows
is a no-op.

Cron entry points:
- scripts/auction_tick.py — every minute (* * * * *) recommended;
  60s default soft-close window means 1-min granularity is fine
- scripts/offer_tick.py — every 15 minutes; offers expire at hour
  granularity so coarse polling is enough

Tests:
- 2 unit (test_models.py): pure transition_scheduled_to_active /
  transition_active_to_ended for state, timestamp, and missing-data branches
- 4 integration auction: SCHEDULED→ACTIVE on start passing, ACTIVE→
  ENDED with winner recorded, ACTIVE→ENDED with no bids leaves winner
  None, idempotent
- 4 integration offer: PENDING past expires → EXPIRED, live offer not
  touched, terminal offer not touched, idempotent

Total: 936 tests pass (was 926 + 10).
This commit is contained in:
russell@unturf.com 2026-05-09 20:56:16 -04:00
parent 31ab7507ba
commit e044c13c74
No known key found for this signature in database
6 changed files with 471 additions and 0 deletions

View file

@ -0,0 +1,94 @@
"""auction_tick — scheduled state transitions for MpsAuction.
Pure functions are unit-testable without a Pyramid environment; the
script entry point in scripts/auction_tick.py drives them with a real
dbsession.
Transitions:
SCHEDULED + start_timestamp passed ACTIVE
ACTIVE + end_timestamp passed ENDED (winner = is_winning bidder)
Idempotent: running the tick twice in a row is a no-op for any auction
that has already transitioned past the matching threshold.
"""
from ..models.auction import (
AUCTION_STATE_ACTIVE,
AUCTION_STATE_ENDED,
AUCTION_STATE_SCHEDULED,
MpsAuction,
MpsBid,
now_timestamp,
)
def transition_scheduled_to_active(auction, now_ms):
"""Pure: returns True iff the auction should transition.
Caller (orchestrator) mutates state."""
if auction.state != AUCTION_STATE_SCHEDULED:
return False
if auction.start_timestamp is None:
return False
return now_ms >= auction.start_timestamp
def transition_active_to_ended(auction, now_ms):
"""Pure: returns True iff the active auction should end now."""
if auction.state != AUCTION_STATE_ACTIVE:
return False
if auction.end_timestamp is None:
return False
return now_ms >= auction.end_timestamp
def tick(dbsession, now_ms=None):
"""Run state transitions on every non-terminal auction.
Returns a dict {activated: int, ended: int} of how many auctions
moved each direction. Caller (script entry point) handles its own
transaction/commit boundary.
"""
if now_ms is None:
now_ms = now_timestamp()
activated = 0
ended = 0
# Pull all auctions that could need a transition. Two simple queries
# avoid scanning the whole table — no MOAD-0001 here.
scheduled = (
dbsession.query(MpsAuction)
.filter(MpsAuction.state == AUCTION_STATE_SCHEDULED)
.filter(MpsAuction.start_timestamp <= now_ms)
.all()
)
for auction in scheduled:
auction.state = AUCTION_STATE_ACTIVE
auction.updated_timestamp = now_ms
activated += 1
active = (
dbsession.query(MpsAuction)
.filter(MpsAuction.state == AUCTION_STATE_ACTIVE)
.filter(MpsAuction.end_timestamp <= now_ms)
.all()
)
for auction in active:
auction.state = AUCTION_STATE_ENDED
auction.updated_timestamp = now_ms
# Record winner from is_winning bid (if any).
winning_bid = (
dbsession.query(MpsBid)
.filter_by(auction_id=auction.id, is_winning=True)
.one_or_none()
)
if winning_bid is not None:
auction.winner_user_id = winning_bid.bidder_user_id
auction.winning_bid_id = winning_bid.id
# Set payment deadline to end + 48h by default.
auction.payment_deadline_timestamp = now_ms + 48 * 3600 * 1000
ended += 1
return {"activated": activated, "ended": ended}

View file

@ -0,0 +1,32 @@
"""offer_tick — auto-expiration for offers past expires_timestamp.
Idempotent: running twice on an already-expired offer is a no-op.
"""
from ..models.offer import (
MpsOffer,
OFFER_STATE_PENDING,
OFFER_STATE_COUNTERED,
now_timestamp,
)
from .offer import expire_offer
def tick(dbsession, now_ms=None):
"""Expire any non-terminal offer past expires_timestamp.
Returns a dict {expired: int}. Caller manages its txn boundary.
"""
if now_ms is None:
now_ms = now_timestamp()
candidates = (
dbsession.query(MpsOffer)
.filter(MpsOffer.state.in_([OFFER_STATE_PENDING, OFFER_STATE_COUNTERED]))
.filter(MpsOffer.expires_timestamp <= now_ms)
.all()
)
expired = 0
for offer in candidates:
expire_offer(offer, now_ms=now_ms)
expired += 1
return {"expired": expired}

View file

@ -0,0 +1,42 @@
"""Cron entry point: run auction state transitions.
Usage:
env/bin/python -m make_post_sell.scripts.auction_tick development.ini
Cron suggestion: every minute (* * * * *). Soft-close gives 60s default
windows so 1-minute granularity is plenty.
"""
import argparse
import sys
from pyramid.paster import bootstrap, get_appsettings, setup_logging
from .. import models
from ..lib.auction_tick import tick
def parse_args(argv):
parser = argparse.ArgumentParser()
parser.add_argument(
"config_uri",
help="Configuration file, e.g., development.ini",
)
return parser.parse_args(argv[1:])
def main(argv=sys.argv):
args = parse_args(argv)
setup_logging(args.config_uri)
env = bootstrap(args.config_uri)
settings = get_appsettings(args.config_uri) # noqa: F841
with env["request"].tm:
result = tick(env["request"].dbsession)
print(
f"auction_tick: activated={result['activated']} ended={result['ended']}"
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,40 @@
"""Cron entry point: run offer auto-expiration.
Usage:
env/bin/python -m make_post_sell.scripts.offer_tick development.ini
Cron suggestion: every 15 minutes offers expire at hour granularity
so this is fine.
"""
import argparse
import sys
from pyramid.paster import bootstrap, get_appsettings, setup_logging
from .. import models
from ..lib.offer_tick import tick
def parse_args(argv):
parser = argparse.ArgumentParser()
parser.add_argument(
"config_uri",
help="Configuration file, e.g., development.ini",
)
return parser.parse_args(argv[1:])
def main(argv=sys.argv):
args = parse_args(argv)
setup_logging(args.config_uri)
env = bootstrap(args.config_uri)
settings = get_appsettings(args.config_uri) # noqa: F841
with env["request"].tm:
result = tick(env["request"].dbsession)
print(f"offer_tick: expired={result['expired']}")
if __name__ == "__main__":
main()

View file

@ -4974,3 +4974,222 @@ class TestCartTotalOverride(DatabaseIntegrationTests):
# Override: $80 not $100.
self.assertEqual(cart.auction_offer_override_in_cents, 8000)
self.assertEqual(cart.total_price_in_cents, 8000)
class TestAuctionTickIntegration(DatabaseIntegrationTests):
"""MPS-20: lib/auction_tick.tick state transitions over time."""
def _shop_product(self):
from ..models.product import Product
shop = Shop(
name="Tick Shop", phone_number="x", billing_address="x",
description="x",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "tick.test"
self.dbsession.add(shop)
product = Product(title="Tick item", description="...")
product.shop = shop
product.price_in_cents = 5000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 1
self.dbsession.add(product)
self.dbsession.flush()
return shop, product
def test_scheduled_auction_activates_when_start_passes(self):
from ..lib.auction_tick import tick
from ..models.auction import (
MpsAuction, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
now_timestamp,
)
shop, product = self._shop_product()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_SCHEDULED
auction.start_timestamp = now_timestamp() - 1000 # passed
auction.end_timestamp = now_timestamp() + 60_000
self.dbsession.add(auction)
self.dbsession.flush()
result = tick(self.dbsession)
self.assertEqual(result["activated"], 1)
self.assertEqual(result["ended"], 0)
self.assertEqual(auction.state, AUCTION_STATE_ACTIVE)
def test_active_auction_ends_when_end_passes_and_records_winner(self):
from ..lib.auction_tick import tick
from ..models.auction import (
MpsAuction, MpsBid, AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
now_timestamp,
)
shop, product = self._shop_product()
bidder = get_or_create_user_by_email(self.dbsession, "bidder@tick.test")
self.dbsession.add(bidder)
self.dbsession.flush()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_ACTIVE
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() - 1000 # passed
self.dbsession.add(auction)
bid = MpsBid(auction=auction, bidder=bidder, amount_in_cents=2000)
bid.is_winning = True
self.dbsession.add(bid)
self.dbsession.flush()
result = tick(self.dbsession)
self.assertEqual(result["ended"], 1)
self.assertEqual(auction.state, AUCTION_STATE_ENDED)
self.assertEqual(auction.winner_user_id, bidder.id)
self.assertEqual(auction.winning_bid_id, bid.id)
self.assertIsNotNone(auction.payment_deadline_timestamp)
def test_active_auction_ends_with_no_bids_no_winner(self):
from ..lib.auction_tick import tick
from ..models.auction import (
MpsAuction, AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
now_timestamp,
)
shop, product = self._shop_product()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_ACTIVE
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() - 1000
self.dbsession.add(auction)
self.dbsession.flush()
result = tick(self.dbsession)
self.assertEqual(result["ended"], 1)
self.assertEqual(auction.state, AUCTION_STATE_ENDED)
self.assertIsNone(auction.winner_user_id)
self.assertIsNone(auction.winning_bid_id)
def test_tick_idempotent(self):
from ..lib.auction_tick import tick
from ..models.auction import (
MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp,
)
shop, product = self._shop_product()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_ACTIVE
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() - 1000
self.dbsession.add(auction)
self.dbsession.flush()
result1 = tick(self.dbsession)
self.assertEqual(result1["ended"], 1)
# Second tick on already-ended auction should be a no-op.
result2 = tick(self.dbsession)
self.assertEqual(result2["ended"], 0)
self.assertEqual(result2["activated"], 0)
class TestOfferTickIntegration(DatabaseIntegrationTests):
"""MPS-21: lib/offer_tick.tick auto-expiration."""
def _shop_product_buyer(self):
from ..models.product import Product
shop = Shop(
name="Offer Tick Shop", phone_number="x", billing_address="x",
description="x",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "ot.test"
self.dbsession.add(shop)
product = Product(title="Offer tick", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
buyer = get_or_create_user_by_email(self.dbsession, "buyer@ot.test")
self.dbsession.add(buyer)
self.dbsession.flush()
return shop, product, buyer
def test_expired_pending_offer_flips_to_expired(self):
from ..lib.offer_tick import tick
from ..models.offer import (
MpsOffer, OFFER_STATE_PENDING, OFFER_STATE_EXPIRED,
now_timestamp,
)
shop, product, buyer = self._shop_product_buyer()
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000,
expires_timestamp=now_timestamp() - 1000, # already past
)
self.dbsession.add(offer)
self.dbsession.flush()
self.assertEqual(offer.state, OFFER_STATE_PENDING)
result = tick(self.dbsession)
self.assertEqual(result["expired"], 1)
self.assertEqual(offer.state, OFFER_STATE_EXPIRED)
def test_live_offer_not_touched(self):
from ..lib.offer_tick import tick
from ..models.offer import (
MpsOffer, OFFER_STATE_PENDING, now_timestamp,
)
shop, product, buyer = self._shop_product_buyer()
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000,
expires_timestamp=now_timestamp() + 86_400_000, # 1 day from now
)
self.dbsession.add(offer)
self.dbsession.flush()
result = tick(self.dbsession)
self.assertEqual(result["expired"], 0)
self.assertEqual(offer.state, OFFER_STATE_PENDING)
def test_terminal_offer_not_touched(self):
from ..lib.offer_tick import tick
from ..models.offer import (
MpsOffer, OFFER_STATE_DECLINED, now_timestamp,
)
shop, product, buyer = self._shop_product_buyer()
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000,
expires_timestamp=now_timestamp() - 1000,
)
offer.state = OFFER_STATE_DECLINED # terminal
self.dbsession.add(offer)
self.dbsession.flush()
result = tick(self.dbsession)
self.assertEqual(result["expired"], 0)
self.assertEqual(offer.state, OFFER_STATE_DECLINED)
def test_tick_idempotent(self):
from ..lib.offer_tick import tick
from ..models.offer import MpsOffer, now_timestamp
shop, product, buyer = self._shop_product_buyer()
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000,
expires_timestamp=now_timestamp() - 1000,
)
self.dbsession.add(offer)
self.dbsession.flush()
result1 = tick(self.dbsession)
self.assertEqual(result1["expired"], 1)
result2 = tick(self.dbsession)
self.assertEqual(result2["expired"], 0)

View file

@ -4754,3 +4754,47 @@ class TestOfferLibPureFunctions(unittest.TestCase):
self.assertEqual(
auto_resolve_open(100, 0, 95, 50), "queue",
)
class TestAuctionTickPureFunctions(unittest.TestCase):
"""MPS-20: lib/auction_tick.py pure transition checks (no DB)."""
def _stub_auction(self, state, start_timestamp=None, end_timestamp=None):
from types import SimpleNamespace
return SimpleNamespace(
state=state,
start_timestamp=start_timestamp,
end_timestamp=end_timestamp,
)
def test_scheduled_to_active_when_start_passed(self):
from ..lib.auction_tick import transition_scheduled_to_active
from ..models.auction import (
AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
)
# Start in past → True.
a = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=1000)
self.assertTrue(transition_scheduled_to_active(a, now_ms=2000))
# Start in future → False.
a2 = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=2000)
self.assertFalse(transition_scheduled_to_active(a2, now_ms=1000))
# Wrong state → False.
a3 = self._stub_auction(AUCTION_STATE_ACTIVE, start_timestamp=1000)
self.assertFalse(transition_scheduled_to_active(a3, now_ms=2000))
# Missing start_timestamp → False.
a4 = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=None)
self.assertFalse(transition_scheduled_to_active(a4, now_ms=2000))
def test_active_to_ended_when_end_passed(self):
from ..lib.auction_tick import transition_active_to_ended
from ..models.auction import (
AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
)
a = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=1000)
self.assertTrue(transition_active_to_ended(a, now_ms=2000))
a2 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=2000)
self.assertFalse(transition_active_to_ended(a2, now_ms=1000))
a3 = self._stub_auction(AUCTION_STATE_ENDED, end_timestamp=1000)
self.assertFalse(transition_active_to_ended(a3, now_ms=2000))
a4 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=None)
self.assertFalse(transition_active_to_ended(a4, now_ms=2000))