@@ -9,21 +9,35 @@ public class MinimalApiExtensionTests
99 private static readonly DateTime Start = new ( 2024 , 10 , 24 ) ;
1010 private static readonly DateTime End = new ( 2024 , 10 , 27 ) ;
1111
12- // Renders nothing but a marker so the assertions can tell "reached Python" from "rejected early".
13- private static string Marker ( Stock s , string start , string end ) => $ "{ s . Ticker } |{ s . FileName } |{ start } |{ end } ";
12+ /// <summary>Stands in for the Python call chain, recording what the endpoint asked it to render.</summary>
13+ private sealed class Recorder
14+ {
15+ public List < ( Stock Stock , string Start , string End , string PlotName ) > Calls { get ; } = [ ] ;
16+
17+ public void Save ( Stock stock , string start , string end , string plotName ) =>
18+ Calls . Add ( ( stock , start , end , plotName ) ) ;
19+ }
1420
1521 [ Theory ]
1622 [ InlineData ( 1 , "AAPL" , "Apple" , "aapl_27.10.24-24.10.24.csv" ) ]
1723 [ InlineData ( 2 , "MSFT" , "Microsoft" , "msft_27.10.24-24.10.24.csv" ) ]
1824 [ InlineData ( 3 , "IBM" , "IBM" , "ibm_27.10.24-24.10.24.csv" ) ]
19- public void KnownTicker_RendersWithResolvedStock ( int id , string ticker , string title , string file )
25+ public void KnownTicker_PassesResolvedStockAndIsoDates ( int id , string ticker , string title , string file )
2026 {
21- var result = MinimalApiExtension . StockPlot ( id , Start , End , Marker ) ;
27+ var recorder = new Recorder ( ) ;
28+
29+ var result = MinimalApiExtension . StockPlot ( id , Start , End , recorder . Save ) ;
30+
31+ var call = Assert . Single ( recorder . Calls ) ;
32+ Assert . Equal ( ticker , call . Stock . Ticker ) ;
33+ Assert . Equal ( title , call . Stock . Title ) ;
34+ Assert . Equal ( file , call . Stock . FileName ) ;
35+ Assert . Equal ( "2024-10-24" , call . Start ) ;
36+ Assert . Equal ( "2024-10-27" , call . End ) ;
2237
2338 var content = Assert . IsType < ContentHttpResult > ( result ) ;
24- Assert . Equal ( $ "{ ticker } |{ file } |2024-10-24|2024-10-27", content . ResponseContent ) ;
2539 Assert . Equal ( "text/html" , content . ContentType ) ;
26- Assert . Equal ( title , MinimalApiExtension . Tickers [ id ] . Title ) ;
40+ Assert . Contains ( $ "/dynamic-plots/ { call . PlotName } .png" , content . ResponseContent ) ;
2741 }
2842
2943 [ Theory ]
@@ -33,47 +47,108 @@ public void KnownTicker_RendersWithResolvedStock(int id, string ticker, string t
3347 [ InlineData ( 99 ) ]
3448 public void UnknownTicker_IsRejectedWithoutCallingPython ( int id )
3549 {
36- var called = false ;
50+ var recorder = new Recorder ( ) ;
3751
38- var result = MinimalApiExtension . StockPlot ( id , Start , End , ( _ , _ , _ ) =>
39- {
40- called = true ;
41- return "should not happen" ;
42- } ) ;
52+ var result = MinimalApiExtension . StockPlot ( id , Start , End , recorder . Save ) ;
4353
4454 var bad = Assert . IsType < BadRequest < string > > ( result ) ;
4555 Assert . Contains ( $ "Unknown ticker '{ id } '", bad . Value ) ;
46- Assert . False ( called ) ;
56+ Assert . Empty ( recorder . Calls ) ;
4757 }
4858
4959 [ Fact ]
50- public void StartAfterEnd_IsRejected ( )
60+ public void StartAfterEnd_IsRejectedWithoutCallingPython ( )
5161 {
52- var result = MinimalApiExtension . StockPlot ( 1 , End , Start , Marker ) ;
62+ var recorder = new Recorder ( ) ;
63+
64+ var result = MinimalApiExtension . StockPlot ( 1 , End , Start , recorder . Save ) ;
5365
5466 var bad = Assert . IsType < BadRequest < string > > ( result ) ;
5567 Assert . Contains ( "startDate must be on or before endDate" , bad . Value ) ;
68+ Assert . Empty ( recorder . Calls ) ;
5669 }
5770
5871 [ Fact ]
5972 public void SameStartAndEnd_IsAllowed ( )
6073 {
61- var result = MinimalApiExtension . StockPlot ( 1 , Start , Start , Marker ) ;
74+ var result = MinimalApiExtension . StockPlot ( 1 , Start , Start , new Recorder ( ) . Save ) ;
6275
6376 Assert . IsType < ContentHttpResult > ( result ) ;
6477 }
6578
6679 [ Fact ]
67- public void RenderFailure_BecomesProblemResponse ( )
80+ public void SaveFailure_BecomesProblemResponse ( )
6881 {
6982 var result = MinimalApiExtension . StockPlot ( 1 , Start , End ,
70- ( _ , _ , _ ) => throw new FileNotFoundException ( "Data/aapl_27.10.24-24.10.24.csv" ) ) ;
83+ ( _ , _ , _ , _ ) => throw new FileNotFoundException ( "Data/aapl_27.10.24-24.10.24.csv" ) ) ;
7184
7285 var problem = Assert . IsType < ProblemHttpResult > ( result ) ;
7386 Assert . Equal ( StatusCodes . Status500InternalServerError , problem . StatusCode ) ;
7487 Assert . Contains ( "Could not plot AAPL" , problem . ProblemDetails . Detail ) ;
7588 }
7689
90+ [ Fact ]
91+ public void ConcurrentRequestsForOneTicker_GetDistinctPlotNames ( )
92+ {
93+ var recorder = new Recorder ( ) ;
94+
95+ var htmlA = ( ContentHttpResult ) MinimalApiExtension . StockPlot ( 1 , Start , End , recorder . Save ) ;
96+ var htmlB = ( ContentHttpResult ) MinimalApiExtension . StockPlot ( 1 , Start , End , recorder . Save ) ;
97+
98+ var ( first , second ) = ( recorder . Calls [ 0 ] . PlotName , recorder . Calls [ 1 ] . PlotName ) ;
99+ Assert . StartsWith ( "plot_AAPL_" , first ) ;
100+ Assert . StartsWith ( "plot_AAPL_" , second ) ;
101+ Assert . NotEqual ( first , second ) ;
102+ Assert . NotEqual ( htmlA . ResponseContent , htmlB . ResponseContent ) ;
103+ }
104+
105+ [ Fact ]
106+ public void SweepStalePlots_DeletesOnlyPlotsPastTheirAge ( )
107+ {
108+ var cwd = Directory . GetCurrentDirectory ( ) ;
109+ var scratch = Directory . CreateTempSubdirectory ( ) . FullName ;
110+ try
111+ {
112+ Directory . SetCurrentDirectory ( scratch ) ;
113+ var plots = Directory . CreateDirectory ( MinimalApiExtension . PlotDirectory ) . FullName ;
114+
115+ var stale = Path . Combine ( plots , "plot_AAPL_old.png" ) ;
116+ var fresh = Path . Combine ( plots , "plot_AAPL_new.png" ) ;
117+ var other = Path . Combine ( plots , "keep-me.txt" ) ;
118+ foreach ( var f in new [ ] { stale , fresh , other } ) File . WriteAllText ( f , "x" ) ;
119+ File . SetLastWriteTimeUtc ( stale , DateTime . UtcNow . AddMinutes ( - 30 ) ) ;
120+
121+ MinimalApiExtension . SweepStalePlots ( TimeSpan . FromMinutes ( 5 ) ) ;
122+
123+ Assert . False ( File . Exists ( stale ) ) ;
124+ Assert . True ( File . Exists ( fresh ) ) ;
125+ Assert . True ( File . Exists ( other ) ) ;
126+ }
127+ finally
128+ {
129+ Directory . SetCurrentDirectory ( cwd ) ;
130+ Directory . Delete ( scratch , recursive : true ) ;
131+ }
132+ }
133+
134+ [ Fact ]
135+ public void SweepStalePlots_IsANoOpWhenThereIsNoPlotDirectory ( )
136+ {
137+ var cwd = Directory . GetCurrentDirectory ( ) ;
138+ var scratch = Directory . CreateTempSubdirectory ( ) . FullName ;
139+ try
140+ {
141+ Directory . SetCurrentDirectory ( scratch ) ;
142+ MinimalApiExtension . SweepStalePlots ( ) ;
143+ Assert . False ( Directory . Exists ( MinimalApiExtension . PlotDirectory ) ) ;
144+ }
145+ finally
146+ {
147+ Directory . SetCurrentDirectory ( cwd ) ;
148+ Directory . Delete ( scratch , recursive : true ) ;
149+ }
150+ }
151+
77152 [ Fact ]
78153 public void EveryTickerMapsToAShippedCsv ( )
79154 {
0 commit comments