diff --git a/backend/main.py b/backend/main.py index a434e29..81f49f6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,3 +1,5 @@ +from datetime import UTC, datetime + from fastapi import FastAPI, HTTPException, Query, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -272,14 +274,33 @@ def get_rmas(): return db_get("rmas", "order=created_at.desc") +# Resolution statuses that end an RMA's lifecycle. Moving into one of these +# stamps rmas.closed_at; moving back out (reopening) clears it. +RMA_CLOSED_STATUSES = {"Completed", "Cancelled"} + + +def utc_now_iso() -> str: + return datetime.now(UTC).isoformat() + + @app.post("/rmas") def create_rma(r: RMA): - return db_post("rmas", model_data(r)) + data = model_data(r) + data["closed_at"] = utc_now_iso() if data["resolution_status"] in RMA_CLOSED_STATUSES else None + return db_post("rmas", data) @app.put("/rmas/{id}") def update_rma(id: str, r: RMA): - return db_patch("rmas", id, model_data(r)) + data = model_data(r) + if data["resolution_status"] in RMA_CLOSED_STATUSES: + # Keep the original close time when an already-closed RMA is edited again. + old = db_get("rmas", f"id=eq.{escape_filter_value(id)}&select=closed_at") + if not (old and old[0].get("closed_at")): + data["closed_at"] = utc_now_iso() + else: + data["closed_at"] = None + return db_patch("rmas", id, data) # --- Dashboard --- diff --git a/backend/test_main.py b/backend/test_main.py index ebd5e80..d12ea09 100644 --- a/backend/test_main.py +++ b/backend/test_main.py @@ -5,6 +5,7 @@ import sys import types +from datetime import datetime from unittest.mock import MagicMock from urllib.parse import quote, unquote @@ -507,6 +508,66 @@ def test_rma_routes_delegate_to_database(client, monkeypatch): assert calls[1][0:3] == ("patch", "rmas", "r1") +def _capture_rma_writes(main, monkeypatch, existing_closed_at=None): + calls = [] + monkeypatch.setattr( + main, "db_get", lambda table, params="": [{"closed_at": existing_closed_at}] + ) + monkeypatch.setattr(main, "db_post", lambda table, data: calls.append(data) or data) + monkeypatch.setattr(main, "db_patch", lambda table, row_id, data: calls.append(data) or data) + return calls + + +def test_create_rma_sets_closed_at_only_for_closed_status(client, monkeypatch): + test_client, main = client + calls = _capture_rma_writes(main, monkeypatch) + + test_client.post("/rmas", json={"ticket_id": "t1", "rma_number": "RMA-1"}) + test_client.post( + "/rmas", + json={"ticket_id": "t1", "rma_number": "RMA-2", "resolution_status": "Completed"}, + ) + + assert calls[0]["closed_at"] is None + assert datetime.fromisoformat(calls[1]["closed_at"]).tzinfo is not None + + +def test_update_rma_stamps_closed_at_when_closing(client, monkeypatch): + test_client, main = client + calls = _capture_rma_writes(main, monkeypatch, existing_closed_at=None) + + test_client.put( + "/rmas/r1", + json={"ticket_id": "t1", "rma_number": "RMA-1", "resolution_status": "Cancelled"}, + ) + + assert datetime.fromisoformat(calls[0]["closed_at"]).tzinfo is not None + + +def test_update_rma_keeps_existing_closed_at(client, monkeypatch): + test_client, main = client + calls = _capture_rma_writes(main, monkeypatch, existing_closed_at="2026-01-01T00:00:00+00:00") + + test_client.put( + "/rmas/r1", + json={"ticket_id": "t1", "rma_number": "RMA-1", "resolution_status": "Completed"}, + ) + + assert "closed_at" not in calls[0] + + +def test_update_rma_clears_closed_at_when_reopened(client, monkeypatch): + test_client, main = client + calls = _capture_rma_writes(main, monkeypatch, existing_closed_at="2026-01-01T00:00:00+00:00") + + test_client.put( + "/rmas/r1", + json={"ticket_id": "t1", "rma_number": "RMA-1", "resolution_status": "In Progress"}, + ) + + assert calls[0]["closed_at"] is None + + def test_dashboard_counts_ticket_and_rma_statuses(client, monkeypatch): test_client, main = client