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>
124 lines
3.8 KiB
Python
124 lines
3.8 KiB
Python
"""Tests for the Zoho timelog parser."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from openpyxl import Workbook
|
|
|
|
from app.services.zoho_parse import parse
|
|
|
|
|
|
FIXTURE_CSV = Path(__file__).parent / "fixtures" / "sample_zoho.csv"
|
|
|
|
|
|
def test_canonical_csv_headers():
|
|
content = FIXTURE_CSV.read_bytes()
|
|
out = parse("sample_zoho.csv", content)
|
|
rows = out["rows"]
|
|
assert out["content_hash"].startswith("sha256:")
|
|
assert out["unrecognised_columns"] == []
|
|
assert len(rows) == 4
|
|
r0 = rows[0]
|
|
assert r0["date"] == date(2026, 5, 4)
|
|
assert r0["employee"] == "Bhakti Doshi"
|
|
assert r0["project"] == "Acme Spring Launch"
|
|
assert r0["hours"] == 7.0
|
|
assert r0["billable"] is True
|
|
# Idle Time → not billable
|
|
assert rows[2]["billable"] is False
|
|
# Fee Related → billable
|
|
assert rows[3]["billable"] is True
|
|
|
|
|
|
def test_aliased_headers():
|
|
csv = (
|
|
"Resource,Project,Total Hours,Log Date,Is Billable\n"
|
|
"Bhakti Doshi,Acme,7.5,2026-05-04,true\n"
|
|
).encode("utf-8")
|
|
out = parse("aliased.csv", csv)
|
|
assert out["unrecognised_columns"] == []
|
|
assert out["rows"][0]["employee"] == "Bhakti Doshi"
|
|
assert out["rows"][0]["hours"] == 7.5
|
|
assert out["rows"][0]["billable"] is True
|
|
assert out["rows"][0]["date"] == date(2026, 5, 4)
|
|
|
|
|
|
def test_unrecognised_header_surfaced():
|
|
csv = (
|
|
"Date,Resource,Total Hours,Wibble Factor\n"
|
|
"2026-05-04,Bhakti,7,5\n"
|
|
).encode("utf-8")
|
|
out = parse("u.csv", csv)
|
|
assert "Wibble Factor" in out["unrecognised_columns"]
|
|
# Known columns still parse.
|
|
assert out["rows"][0]["employee"] == "Bhakti"
|
|
assert out["rows"][0]["hours"] == 7.0
|
|
|
|
|
|
def test_xlsx_path():
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.append(["Date", "Resource Name", "Project Title", "Task", "Hours", "Billable"])
|
|
ws.append(["2026-05-04", "Bhakti Doshi", "Acme", "Design", 7.5, "Yes"])
|
|
buf = io.BytesIO()
|
|
wb.save(buf)
|
|
buf.seek(0)
|
|
out = parse("up.xlsx", buf.read())
|
|
assert out["rows"][0]["employee"] == "Bhakti Doshi"
|
|
assert out["rows"][0]["hours"] == 7.5
|
|
assert out["rows"][0]["date"] == date(2026, 5, 4)
|
|
assert out["rows"][0]["billable"] is True
|
|
|
|
|
|
def test_empty_rows_skipped():
|
|
csv = (
|
|
"Date,Resource,Hours\n"
|
|
"\n"
|
|
"2026-05-04,Bhakti,7\n"
|
|
",,\n"
|
|
).encode("utf-8")
|
|
out = parse("blank.csv", csv)
|
|
assert len(out["rows"]) == 1
|
|
|
|
|
|
def test_hh_mm_hours_parsed():
|
|
csv = (
|
|
"Date,Resource,Hours\n"
|
|
"2026-05-04,Bhakti,7:30\n"
|
|
).encode("utf-8")
|
|
out = parse("hhmm.csv", csv)
|
|
assert out["rows"][0]["hours"] == pytest.approx(7.5)
|
|
|
|
|
|
def test_content_hash_stable():
|
|
out1 = parse("a.csv", FIXTURE_CSV.read_bytes())
|
|
out2 = parse("a.csv", FIXTURE_CSV.read_bytes())
|
|
assert out1["content_hash"] == out2["content_hash"]
|
|
|
|
|
|
def test_billing_type_header_populates_billingType_and_billable():
|
|
"""When the upload uses a 'Billing Type' header, each row gains
|
|
`billingType` (lowercased) and `billable` is cross-filled from it."""
|
|
csv = (
|
|
"Date,Resource,Total Hours,Billing Type\n"
|
|
"2026-05-04,Bhakti,7,Client Related\n"
|
|
"2026-05-05,Bhakti,8,Leave Hours\n"
|
|
"2026-05-06,Bhakti,4,Idle Time\n"
|
|
"2026-05-07,Bhakti,6,Fee Related\n"
|
|
).encode("utf-8")
|
|
out = parse("bt.csv", csv)
|
|
assert out["unrecognised_columns"] == []
|
|
rows = out["rows"]
|
|
assert len(rows) == 4
|
|
assert rows[0]["billingType"] == "client related"
|
|
assert rows[0]["billable"] is True
|
|
assert rows[1]["billingType"] == "leave hours"
|
|
assert rows[1]["billable"] is False
|
|
assert rows[2]["billingType"] == "idle time"
|
|
assert rows[2]["billable"] is False
|
|
assert rows[3]["billingType"] == "fee related"
|
|
assert rows[3]["billable"] is True
|