tests/test_clock.py
Rama main · réplica de lectura del repositorio original.
"""Dates are answered in the portfolio's timezone, not the container's.
Every test here picks an instant where the two disagree — the hours between
midnight in Melbourne and midnight in UTC — because that window is the entire
bug. A container runs in UTC, so for the first ten hours of every Australian
morning a UTC container believes it is still yesterday: a trade entered at 8am
was "in the future", and the new financial year arrived a day late.
"""
from __future__ import annotations
import datetime as dt
import pytest
from freezegun import freeze_time
import factories as fac
from app import clock, fyreport
from app.settings import DEFAULT_TIMEZONE, PortfolioSettings
# 2026-08-01 23:00 UTC is 2026-08-02 09:00 in Melbourne (AEST, +10). The two
# calendars disagree about what day it is, which is the only interesting case.
EARLY_MORNING_UTC = "2026-08-01 23:00:00"
MELBOURNE_DATE = dt.date(2026, 8, 2)
UTC_DATE = dt.date(2026, 8, 1)
@pytest.fixture(autouse=True)
def melbourne():
"""Every test in this file runs with the clock configured as production is."""
clock.configure(DEFAULT_TIMEZONE)
yield
clock.configure(DEFAULT_TIMEZONE)
# --------------------------------------------------------------------------- #
# The clock itself
# --------------------------------------------------------------------------- #
@freeze_time(EARLY_MORNING_UTC)
def test_today_is_the_local_date_not_the_utc_one():
assert dt.datetime.now(dt.timezone.utc).date() == UTC_DATE # what the box thinks
assert clock.today() == MELBOURNE_DATE # what the app must use
@freeze_time(EARLY_MORNING_UTC)
def test_now_is_an_aware_local_instant():
moment = clock.now()
assert moment.tzinfo is not None
assert (moment.hour, moment.date()) == (9, MELBOURNE_DATE)
def test_configuring_an_unknown_zone_fails_loudly():
"""A typo in config.yaml must stop the app at boot rather than quietly
serving dates from somewhere else."""
from zoneinfo import ZoneInfoNotFoundError
with pytest.raises(ZoneInfoNotFoundError):
clock.configure("Australia/Nowhere")
@freeze_time(EARLY_MORNING_UTC)
def test_the_zone_is_configurable_not_hardcoded():
"""The app is Australian by default, not by assumption — someone in another
country configures their own zone and every date follows it."""
clock.configure("Europe/London") # 2026-08-02 00:00 UTC+1 → still 1 Aug
assert clock.today() == dt.date(2026, 8, 2)
clock.configure("America/New_York") # 2026-08-01 19:00 EDT
assert clock.today() == dt.date(2026, 8, 1)
# --------------------------------------------------------------------------- #
# Settings
# --------------------------------------------------------------------------- #
# Settings priority is env > YAML > constructor defaults, so these drive the
# values through the environment — passing kwargs would be silently overridden
# by tests/config.test.yaml, and the test would prove nothing.
def test_the_price_feed_follows_the_app_timezone_by_default(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "Europe/Berlin")
settings = PortfolioSettings()
assert settings.timezone == "Europe/Berlin"
assert settings.price_feed.timezone == "Europe/Berlin"
def test_the_price_feed_can_still_keep_its_own_hours(monkeypatch):
"""A market can trade in a different zone from the one you count days in."""
monkeypatch.setenv("APP_TIMEZONE", "Europe/Berlin")
monkeypatch.setenv("APP_PRICE_FEED__TIMEZONE", "Australia/Sydney")
settings = PortfolioSettings()
assert settings.price_feed.timezone == "Australia/Sydney"
# --------------------------------------------------------------------------- #
# What it fixes
# --------------------------------------------------------------------------- #
@freeze_time(EARLY_MORNING_UTC)
def test_a_trade_dated_today_is_not_rejected_as_being_in_the_future(client,
session_factory):
"""The headline symptom: recording this morning's trade before ~10am was
refused, because the container's "today" was still yesterday."""
from sqlalchemy import select
from app.models import Trade
from test_routes import bind_to_only_portfolio, make_login, session_csrf
make_login(client, session_factory)
with session_factory() as s:
bind_to_only_portfolio(s)
acme = fac.make_instrument(s, "ACME", name="Acme Industries")
fac.hold(s, acme)
s.commit()
acme_id = acme.id
resp = client.post(
"/trade/new",
data={"instrument_id": str(acme_id), "type": "buy",
"trade_date": MELBOURNE_DATE.isoformat(), # "today", locally
"quantity": "10", "unit_price": "4.00", "brokerage": "0",
"_csrf": session_csrf(session_factory)},
headers={"accept": "text/html"}, follow_redirects=False)
assert resp.status_code == 303
with session_factory() as s:
bind_to_only_portfolio(s)
assert s.scalars(select(Trade)).one().date == MELBOURNE_DATE
@freeze_time(EARLY_MORNING_UTC)
def test_tomorrow_is_still_refused(client, session_factory):
"""The fix must move the boundary, not remove it."""
from test_routes import bind_to_only_portfolio, make_login, session_csrf
make_login(client, session_factory)
with session_factory() as s:
bind_to_only_portfolio(s)
acme = fac.make_instrument(s, "ACME")
s.commit()
acme_id = acme.id
resp = client.post(
"/trade/new",
data={"instrument_id": str(acme_id), "type": "buy",
"trade_date": (MELBOURNE_DATE + dt.timedelta(days=1)).isoformat(),
"quantity": "1", "unit_price": "1.00", "brokerage": "0",
"_csrf": session_csrf(session_factory)},
headers={"accept": "text/html"})
assert "future" in resp.text.lower()
# 2026-06-30 14:00 UTC is 2026-07-01 00:00 in Melbourne: the instant the
# Australian financial year turns over.
@freeze_time("2026-06-30 14:00:00")
def test_the_new_financial_year_arrives_on_1_july_locally(pf):
"""A new FY must appear automatically on 1 July.
Under UTC it appeared a day late, because 1 July in Melbourne is still
30 June in London."""
acme = fac.make_instrument(pf, "ACME")
fac.add_trade(pf, acme, "2021-03-01", "buy", 10, "5.00")
pf.commit()
assert clock.today() == dt.date(2026, 7, 1)
# July belongs to the FY *ending* 2027, and it must be offered immediately.
assert fyreport.available_fys(pf)[0] == 2027
@freeze_time("2026-06-30 13:00:00")
def test_the_old_financial_year_is_still_current_an_hour_earlier(pf):
"""23:00 on 30 June, locally — the FY has not turned over yet."""
acme = fac.make_instrument(pf, "ACME")
fac.add_trade(pf, acme, "2021-03-01", "buy", 10, "5.00")
pf.commit()
assert clock.today() == dt.date(2026, 6, 30)
assert fyreport.available_fys(pf)[0] == 2026