22import json
33import logging
44import warnings
5+ from datetime import timedelta
56from io import StringIO
67
78import pandas as pd
89from bs4 import BeautifulSoup
910from django .contrib .auth .models import User
11+ from django .core .cache import cache
1012from django .db .models import F
1113from django .urls import reverse
14+ from django .utils .http import http_date
15+ from django .utils .timezone import now
1216from rest_framework .test import APITestCase
1317
1418from baseline .models import (
1923from common .tests .factories import ClassifiedProductFactory , CountryFactory
2024from metadata .models import LivelihoodActivityScenario
2125from metadata .tests .factories import (
26+ CharacteristicGroupFactory ,
2227 LivelihoodCategoryFactory ,
2328 WealthCharacteristicFactory ,
2429 WealthGroupCategoryFactory ,
@@ -595,6 +600,125 @@ def test_filter_by_wealth_characteristic(self):
595600 self .assertEqual (response .status_code , 200 )
596601 self .assertEqual (len (response .json ()), 1 )
597602
603+ def test_as_of_date_filter_returns_valid_baselines (self ):
604+ """
605+ Test that the as_of_date filter returns only baselines valid as of the specified date.
606+ """
607+ today = datetime .date .today ()
608+
609+ # Baseline that expired in the past
610+ expired_baseline = LivelihoodZoneBaselineFactory (
611+ valid_from_date = today - datetime .timedelta (days = 365 ),
612+ valid_to_date = today - datetime .timedelta (days = 30 ),
613+ )
614+
615+ # Baseline currently valid
616+ current_baseline = LivelihoodZoneBaselineFactory (
617+ valid_from_date = today - datetime .timedelta (days = 30 ),
618+ valid_to_date = today + datetime .timedelta (days = 365 ),
619+ )
620+
621+ # Baseline that starts in the future (not yet valid)
622+ future_baseline = LivelihoodZoneBaselineFactory (
623+ valid_from_date = today + datetime .timedelta (days = 30 ),
624+ valid_to_date = today + datetime .timedelta (days = 365 ),
625+ )
626+
627+ # Test default behavior (no as_of_date param) - should return all baselines
628+ response = self .client .get (self .url )
629+ self .assertEqual (response .status_code , 200 )
630+ baseline_ids = [b ["id" ] for b in response .json ()]
631+ self .assertIn (current_baseline .id , baseline_ids )
632+ self .assertIn (expired_baseline .id , baseline_ids )
633+ self .assertIn (future_baseline .id , baseline_ids )
634+
635+ # Test with explicit as_of_date=today - should filter to current baselines
636+ response = self .client .get (self .url , {"as_of_date" : today .isoformat ()})
637+ self .assertEqual (response .status_code , 200 )
638+ baseline_ids = [b ["id" ] for b in response .json ()]
639+ self .assertIn (current_baseline .id , baseline_ids )
640+ self .assertNotIn (expired_baseline .id , baseline_ids )
641+ self .assertNotIn (future_baseline .id , baseline_ids )
642+
643+ # Test with a past date when expired_baseline was still valid
644+ # current_baseline hadn't started yet, so it should be excluded
645+ past_date = today - datetime .timedelta (days = 180 )
646+ response = self .client .get (self .url , {"as_of_date" : past_date .isoformat ()})
647+ self .assertEqual (response .status_code , 200 )
648+ baseline_ids = [b ["id" ] for b in response .json ()]
649+ self .assertIn (expired_baseline .id , baseline_ids )
650+ self .assertNotIn (current_baseline .id , baseline_ids ) # hadn't started yet
651+ self .assertNotIn (future_baseline .id , baseline_ids )
652+
653+ # Test with a future date when future_baseline will be valid
654+ future_date = today + datetime .timedelta (days = 60 )
655+ response = self .client .get (self .url , {"as_of_date" : future_date .isoformat ()})
656+ self .assertEqual (response .status_code , 200 )
657+ baseline_ids = [b ["id" ] for b in response .json ()]
658+ self .assertNotIn (expired_baseline .id , baseline_ids )
659+ self .assertIn (current_baseline .id , baseline_ids )
660+ self .assertIn (future_baseline .id , baseline_ids )
661+
662+ # Baseline with null valid_from_date (valid from the beginning of time)
663+ baseline_no_from = LivelihoodZoneBaselineFactory (
664+ valid_from_date = None ,
665+ valid_to_date = today + datetime .timedelta (days = 365 ),
666+ )
667+ # Baseline with null valid_to_date (valid indefinitely)
668+ baseline_no_to = LivelihoodZoneBaselineFactory (
669+ valid_from_date = today - datetime .timedelta (days = 365 ),
670+ valid_to_date = None ,
671+ )
672+ # Baseline with both dates null (always valid)
673+ baseline_no_dates = LivelihoodZoneBaselineFactory (
674+ valid_from_date = None ,
675+ valid_to_date = None ,
676+ )
677+ # Test default behavior - all three should be returned
678+ response = self .client .get (self .url )
679+ self .assertEqual (response .status_code , 200 )
680+ baseline_ids = [b ["id" ] for b in response .json ()]
681+ self .assertIn (baseline_no_from .id , baseline_ids )
682+ self .assertIn (baseline_no_to .id , baseline_ids )
683+ self .assertIn (baseline_no_dates .id , baseline_ids )
684+
685+ def test_conditional_request_headers (self ):
686+ cache .clear () # Clear cache to ensure clean state
687+
688+ # Test that 200 response includes ETag, Last-Modified, Cache-Control, and Expires headers
689+ response = self .client .get (self .url )
690+ self .assertEqual (response .status_code , 200 )
691+ self .assertIn ("ETag" , response .headers )
692+ self .assertIn ("Last-Modified" , response .headers )
693+ self .assertIn ("Cache-Control" , response .headers )
694+ self .assertIn ("Expires" , response .headers )
695+ self .assertTrue (response .headers ["ETag" ].startswith ('W/"' )) # Weak ETag format
696+
697+ # Test If-None-Match returns 304 when not modified
698+ etag = response .headers ["ETag" ]
699+ cache .clear ()
700+ response = self .client .get (self .url , HTTP_IF_NONE_MATCH = etag )
701+ self .assertEqual (response .status_code , 304 )
702+ self .assertIn ("Cache-Control" , response .headers )
703+ self .assertIn ("Expires" , response .headers )
704+
705+ # Test If-None-Match returns 200 when data is modified
706+ cache .clear () # Clear cache before testing modified data
707+ baseline = self .data [0 ]
708+ baseline .population_source = "Updated source"
709+ baseline .save ()
710+ response = self .client .get (self .url , HTTP_IF_NONE_MATCH = etag )
711+ self .assertEqual (response .status_code , 200 )
712+ self .assertNotEqual (response .headers ["ETag" ], etag )
713+
714+ # Test If-Modified-Since with future date returns 304
715+ cache .clear ()
716+ future_date = http_date ((now () + timedelta (days = 1 )).timestamp ())
717+ response = self .client .get (self .url , HTTP_IF_MODIFIED_SINCE = future_date )
718+ self .assertEqual (response .status_code , 304 )
719+ self .assertIn ("Cache-Control" , response .headers )
720+ self .assertIn ("Expires" , response .headers )
721+
598722
599723class LivelihoodZoneBaselineFacetedSearchViewTestCase (APITestCase ):
600724 def setUp (self ):
@@ -641,7 +765,8 @@ def test_search_with_product(self):
641765 # Apply the filters to the baseline
642766 baseline_url = reverse ("livelihoodzonebaseline-list" )
643767 response = self .client .get (
644- baseline_url , {search_data ["products" ][0 ]["filter" ]: search_data ["products" ][0 ]["value" ]}
768+ baseline_url ,
769+ {search_data ["products" ][0 ]["filter" ]: search_data ["products" ][0 ]["value" ]},
645770 )
646771 self .assertEqual (response .status_code , 200 )
647772 self .assertEqual (len (json .loads (response .content )), 2 )
@@ -650,7 +775,10 @@ def test_search_with_product(self):
650775 self .assertTrue (any (d ["name" ] == self .baseline3 .name for d in data ))
651776 self .assertFalse (any (d ["name" ] == self .baseline2 .name for d in data ))
652777
653- response = self .client .get (baseline_url , {search_data ["items" ][0 ]["filter" ]: search_data ["items" ][0 ]["value" ]})
778+ response = self .client .get (
779+ baseline_url ,
780+ {search_data ["items" ][0 ]["filter" ]: search_data ["items" ][0 ]["value" ]},
781+ )
654782 self .assertEqual (response .status_code , 200 )
655783 self .assertEqual (len (json .loads (response .content )), 1 )
656784 data = json .loads (response .content )
@@ -1499,11 +1627,13 @@ def setUpTestData(cls):
14991627 wealth_group_category = cls .very_poor_wg ,
15001628 community = cls .community ,
15011629 )
1630+ # Create characteristic group
1631+ cls .population_group = CharacteristicGroupFactory (code = "Population" , name_en = "Population" )
15021632 # Create wealth characteristics
15031633 cls .char_with_group = WealthCharacteristicFactory (
15041634 code = "household size" ,
15051635 name_en = "Household size" ,
1506- characteristic_group = "Population" ,
1636+ characteristic_group = cls . population_group ,
15071637 )
15081638 cls .char_without_group = WealthCharacteristicFactory (
15091639 code = "other" ,
0 commit comments