Ctrl K
ComunidadGuías…

tests/test_clock.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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