loreal-utilisation-dept/backend/tests/test_airtable_cache.py
DJP e1db93ad4a backend + frontend: leave hours, server-side bookings filter, dynamic meta, defensive charts
Backend (33/33 tests, +5 new):

- Split Zoho parser's canonical "billable" into "billable" (bool column)
  and "billingType" (string column with values like "Client Related" /
  "Leave Hours" / "Idle Time"). Each parsed row now carries both, and
  billable is cross-filled from billingType when only the latter is
  present.
- Merge service computes leaveHours separately from non_billable_h: any
  row with billingType "leave hours"/"leave" lands in the leave bucket
  and is no longer double-counted as non-billable.
- UtilisationSummaryRow gains leaveHours: float; TimelogRow gains
  billingType: str | None.
- /api/airtable/bookings accepts ?department=&name= (comma-separated
  multi-value), folded into the filterByFormula alongside the date
  overlap. Apostrophes in names are escaped. Cache key now includes
  the filter values so different selections don't collide.
- /api/airtable/meta computes departments + employmentTypes from a
  live fetch_resources call (sorted distinct), falls back to the
  hardcoded lists on any exception. billingTypes/bookingStatuses
  stay static.
- Logout cookie now mirrors the login cookie's HttpOnly / Secure /
  SameSite / Path attributes with max_age=0 and empty value, for
  consistency.

Frontend (typecheck/lint/build clean):

- types.ts: UtilisationSummaryRow.leaveHours: number.
- BillabilityBreakdown uses r.leaveHours directly; idle becomes
  max(0, available - billable - nonBillable - leave). Capped to top
  20 employees by (available + billable) with "Other (N)" rollup;
  Legend replaced with compact inline swatches.
- BookingVsActual and FTEvsFreelancer: same top-20 + Other treatment
  to prevent the ProjectLoad-style x-axis explosion at scale.
- Defensive sweep on WeeklyUtilisation, MonthlyUtilisation,
  BookingVsActual, FTEvsFreelancer: null-coerce sort keys, Number()-
  guard arithmetic, skip rows with no usable period/employee.
- getBookings signature gains department + name; Resourcing passes
  them through. Client-side visibleBookings filter retained as
  belt-and-braces since linked-lookup filterByFormula on Airtable
  can be flaky.
- Tutorial steps.ts restructured to cover the new chart and CSV
  export tags; existing TutorialOverlay defensive selector check
  preserved.
- ErrorBoundary: removed dead eslint-disable directive flagged by
  --report-unused-disable-directives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:48:12 -04:00

127 lines
4 KiB
Python

"""Tests for the TTL async cache + Airtable cache wiring."""
from __future__ import annotations
import asyncio
from datetime import date
import pytest
from app.services.cache import TTLAsyncCache
# NOTE: don't import app.services.airtable_fetch at module level — it pulls
# in app.config at collection time, which can race with the conftest
# session-autouse env-seeding fixture and leave app.auth.local holding a
# stale `settings` object (empty ADMIN_PASSWORD_BCRYPT) for the rest of
# the suite. Importing inside each test that needs it is safe because by
# then the autouse fixture has run and the env vars are populated.
@pytest.mark.asyncio
async def test_second_call_hits_cache():
cache = TTLAsyncCache(ttl=60)
counter = {"n": 0}
async def loader():
counter["n"] += 1
return {"v": counter["n"]}
a = await cache.get_or_set("k", loader)
b = await cache.get_or_set("k", loader)
assert a == b == {"v": 1}
assert counter["n"] == 1
@pytest.mark.asyncio
async def test_invalidate_forces_reload():
cache = TTLAsyncCache(ttl=60)
counter = {"n": 0}
async def loader():
counter["n"] += 1
return counter["n"]
assert await cache.get_or_set("k", loader) == 1
cache.invalidate("k")
assert await cache.get_or_set("k", loader) == 2
@pytest.mark.asyncio
async def test_lock_prevents_thundering_herd():
cache = TTLAsyncCache(ttl=60)
counter = {"n": 0}
async def slow_loader():
# Yield once so other coroutines have a chance to enter.
counter["n"] += 1
await asyncio.sleep(0.05)
return counter["n"]
results = await asyncio.gather(
cache.get_or_set("k", slow_loader),
cache.get_or_set("k", slow_loader),
cache.get_or_set("k", slow_loader),
cache.get_or_set("k", slow_loader),
cache.get_or_set("k", slow_loader),
)
# All callers see the same cached value, loader ran only once.
assert counter["n"] == 1
assert all(r == 1 for r in results)
def test_bookings_filter_combines_date_and_multivalue_filters():
"""Date overlap + multi-value department + name with an apostrophe
must all combine into a single AND() with properly-escaped quotes."""
from app.services.airtable_fetch import _bookings_filter
formula = _bookings_filter(
date(2026, 5, 4),
date(2026, 5, 17),
department="Creative Team,Operation Team",
name="O'Brien,Bhakti Doshi",
)
assert formula is not None
# Date overlap clauses
assert "IS_BEFORE({Start Date}, '2026-05-17')" in formula
assert "IS_AFTER({End Date}, '2026-05-04')" in formula
# Department OR
assert (
"OR({Department (from Resource Name)}='Creative Team', "
"{Department (from Resource Name)}='Operation Team')"
) in formula
# Name OR with the apostrophe escaped
assert "{Resource Name (from Resource)}='O\\'Brien'" in formula
assert "{Resource Name (from Resource)}='Bhakti Doshi'" in formula
# Wrapped in AND(...) since we have multiple top-level clauses.
assert formula.startswith("AND(") and formula.endswith(")")
def test_bookings_filter_no_filters_returns_none():
from app.services.airtable_fetch import _bookings_filter
assert _bookings_filter(None, None, None, None) is None
def test_bookings_filter_single_department_no_or_wrapper():
"""A single value shouldn't get wrapped in OR(...)."""
from app.services.airtable_fetch import _bookings_filter
formula = _bookings_filter(None, None, "Creative Team", None)
assert formula == "{Department (from Resource Name)}='Creative Team'"
@pytest.mark.asyncio
async def test_refresh_bypasses_cache():
"""Simulate the 'refresh=true' behaviour the router uses."""
cache = TTLAsyncCache(ttl=60)
counter = {"n": 0}
async def loader():
counter["n"] += 1
return counter["n"]
await cache.get_or_set("k", loader)
# Router invalidates, then re-fetches.
cache.invalidate("k")
await cache.get_or_set("k", loader)
assert counter["n"] == 2