@@ -112,6 +112,28 @@ public async Task ResolveAllTimersForCallAsync_ReturnsEmpty_WhenNoConfigForTarge
112112 result . Should ( ) . BeEmpty ( ) ;
113113 }
114114
115+ [ Test ]
116+ public async Task ResolveAllTimersForCallAsync_ResolvesCallTypeNameToId_ForOverrideMatching ( )
117+ {
118+ // Call.Type stores the call type NAME, not the id — the resolver must look the
119+ // id up from the department's call types for override matching to work.
120+ var call = new Call { CallId = 1 , DepartmentId = 10 , Type = "Structure Fire" , Priority = 3 , CheckInTimersEnabled = true } ;
121+ var overrides = new List < CheckInTimerOverride >
122+ {
123+ new CheckInTimerOverride { TimerTargetType = 0 , CallTypeId = 7 , CallPriority = 3 , DurationMinutes = 12 , WarningThresholdMinutes = 2 , IsEnabled = true }
124+ } ;
125+ _configRepo . Setup ( x => x . GetByDepartmentIdAsync ( 10 ) ) . ReturnsAsync ( new List < CheckInTimerConfig > ( ) ) ;
126+ _callsService . Setup ( x => x . GetCallTypesForDepartmentAsync ( 10 ) )
127+ . ReturnsAsync ( new List < CallType > { new CallType { CallTypeId = 7 , Type = "Structure Fire" } } ) ;
128+ _overrideRepo . Setup ( x => x . GetMatchingOverridesAsync ( 10 , 7 , 3 ) ) . ReturnsAsync ( overrides ) ;
129+
130+ var result = await _service . ResolveAllTimersForCallAsync ( call ) ;
131+
132+ result . Should ( ) . HaveCount ( 1 ) ;
133+ result [ 0 ] . DurationMinutes . Should ( ) . Be ( 12 ) ;
134+ result [ 0 ] . IsFromOverride . Should ( ) . BeTrue ( ) ;
135+ }
136+
115137 #endregion Timer Resolution
116138
117139 #region Timer Status
@@ -135,9 +157,10 @@ public async Task GetActiveTimerStatusesForCallAsync_Green_WhenElapsedLessThanDu
135157 }
136158
137159 [ Test ]
138- public async Task GetActiveTimerStatusesForCallAsync_Warning_WhenElapsedBetweenDurationAndThreshold ( )
160+ public async Task GetActiveTimerStatusesForCallAsync_Warning_WhenWithinWarningThresholdOfDue ( )
139161 {
140- var call = new Call { CallId = 1 , DepartmentId = 10 , State = ( int ) CallStates . Active , CheckInTimersEnabled = true , LoggedOn = DateTime . UtcNow . AddMinutes ( - 32 ) } ;
162+ // Duration 30 / warning 5: elapsed 27 leaves 3 minutes remaining -> Warning
163+ var call = new Call { CallId = 1 , DepartmentId = 10 , State = ( int ) CallStates . Active , CheckInTimersEnabled = true , LoggedOn = DateTime . UtcNow . AddMinutes ( - 27 ) } ;
141164 var configs = new List < CheckInTimerConfig >
142165 {
143166 new CheckInTimerConfig { TimerTargetType = 0 , DurationMinutes = 30 , WarningThresholdMinutes = 5 , IsEnabled = true }
@@ -153,9 +176,10 @@ public async Task GetActiveTimerStatusesForCallAsync_Warning_WhenElapsedBetweenD
153176 }
154177
155178 [ Test ]
156- public async Task GetActiveTimerStatusesForCallAsync_Critical_WhenElapsedExceedsThreshold ( )
179+ public async Task GetActiveTimerStatusesForCallAsync_Critical_WhenCheckInIsDue ( )
157180 {
158- var call = new Call { CallId = 1 , DepartmentId = 10 , State = ( int ) CallStates . Active , CheckInTimersEnabled = true , LoggedOn = DateTime . UtcNow . AddMinutes ( - 40 ) } ;
181+ // Duration 30 / warning 5: elapsed 32 means the check-in is overdue -> Critical
182+ var call = new Call { CallId = 1 , DepartmentId = 10 , State = ( int ) CallStates . Active , CheckInTimersEnabled = true , LoggedOn = DateTime . UtcNow . AddMinutes ( - 32 ) } ;
159183 var configs = new List < CheckInTimerConfig >
160184 {
161185 new CheckInTimerConfig { TimerTargetType = 0 , DurationMinutes = 30 , WarningThresholdMinutes = 5 , IsEnabled = true }
@@ -190,6 +214,40 @@ public async Task GetActiveTimerStatusesForCallAsync_EmptyList_WhenTimersNotEnab
190214 result . Should ( ) . BeEmpty ( ) ;
191215 }
192216
217+ [ Test ]
218+ public async Task GetActiveTimerStatusesForCallAsync_UnitTypeTimer_MatchesCheckInsByUnitsOfThatType ( )
219+ {
220+ var call = new Call { CallId = 1 , DepartmentId = 10 , State = ( int ) CallStates . Active , CheckInTimersEnabled = true , LoggedOn = DateTime . UtcNow . AddMinutes ( - 40 ) } ;
221+ var configs = new List < CheckInTimerConfig >
222+ {
223+ new CheckInTimerConfig { TimerTargetType = ( int ) CheckInTimerTargetType . UnitType , UnitTypeId = 2 , DurationMinutes = 30 , WarningThresholdMinutes = 5 , IsEnabled = true }
224+ } ;
225+ _configRepo . Setup ( x => x . GetByDepartmentIdAsync ( 10 ) ) . ReturnsAsync ( configs ) ;
226+ _overrideRepo . Setup ( x => x . GetMatchingOverridesAsync ( 10 , null , 0 ) ) . ReturnsAsync ( new List < CheckInTimerOverride > ( ) ) ;
227+ _unitsService . Setup ( x => x . GetUnitsForDepartmentAsync ( 10 ) ) . ReturnsAsync ( new List < Unit >
228+ {
229+ new Unit { UnitId = 5 , DepartmentId = 10 , Type = "Engine" } ,
230+ new Unit { UnitId = 6 , DepartmentId = 10 , Type = "Tender" }
231+ } ) ;
232+ _unitsService . Setup ( x => x . GetUnitTypesForDepartmentAsync ( 10 ) ) . ReturnsAsync ( new List < UnitType >
233+ {
234+ new UnitType { UnitTypeId = 2 , DepartmentId = 10 , Type = "Engine" } ,
235+ new UnitType { UnitTypeId = 3 , DepartmentId = 10 , Type = "Tender" }
236+ } ) ;
237+ // Unit 6 (wrong type) checked in most recently; unit 5 (matching type) earlier.
238+ _recordRepo . Setup ( x => x . GetByCallIdAsync ( 1 ) ) . ReturnsAsync ( new List < CheckInRecord >
239+ {
240+ new CheckInRecord { CheckInRecordId = "r1" , CheckInType = ( int ) CheckInTimerTargetType . UnitType , UnitId = 5 , Timestamp = DateTime . UtcNow . AddMinutes ( - 2 ) } ,
241+ new CheckInRecord { CheckInRecordId = "r2" , CheckInType = ( int ) CheckInTimerTargetType . UnitType , UnitId = 6 , Timestamp = DateTime . UtcNow . AddMinutes ( - 1 ) }
242+ } ) ;
243+
244+ var result = await _service . GetActiveTimerStatusesForCallAsync ( call ) ;
245+
246+ result . Should ( ) . HaveCount ( 1 ) ;
247+ result [ 0 ] . UnitId . Should ( ) . Be ( 5 ) ;
248+ result [ 0 ] . Status . Should ( ) . Be ( "Green" ) ;
249+ }
250+
193251 #endregion Timer Status
194252
195253 #region Check-in Operations
@@ -274,8 +332,79 @@ public async Task DeleteTimerConfigAsync_ReturnsFalse_WhenConfigNotFound()
274332 result . Should ( ) . BeFalse ( ) ;
275333 }
276334
335+ [ Test ]
336+ public async Task SaveTimerConfigAsync_Throws_WhenDurationInvalid ( )
337+ {
338+ var config = new CheckInTimerConfig { DepartmentId = 10 , TimerTargetType = 0 , DurationMinutes = 0 , WarningThresholdMinutes = 5 } ;
339+
340+ Func < Task > act = async ( ) => await _service . SaveTimerConfigAsync ( config ) ;
341+
342+ await act . Should ( ) . ThrowAsync < InvalidOperationException > ( ) ;
343+ }
344+
345+ [ Test ]
346+ public async Task SaveTimerConfigAsync_Throws_WhenWarningThresholdEqualsDuration ( )
347+ {
348+ var config = new CheckInTimerConfig { DepartmentId = 10 , TimerTargetType = 0 , DurationMinutes = 30 , WarningThresholdMinutes = 30 } ;
349+
350+ Func < Task > act = async ( ) => await _service . SaveTimerConfigAsync ( config ) ;
351+
352+ await act . Should ( ) . ThrowAsync < InvalidOperationException > ( ) ;
353+ }
354+
355+ [ Test ]
356+ public async Task SaveTimerConfigAsync_Throws_WhenWarningThresholdExceedsDuration ( )
357+ {
358+ var config = new CheckInTimerConfig { DepartmentId = 10 , TimerTargetType = 0 , DurationMinutes = 15 , WarningThresholdMinutes = 30 } ;
359+
360+ Func < Task > act = async ( ) => await _service . SaveTimerConfigAsync ( config ) ;
361+
362+ await act . Should ( ) . ThrowAsync < InvalidOperationException > ( ) ;
363+ }
364+
365+ [ Test ]
366+ public async Task SaveTimerConfigAsync_ClearsUnitTypeId_ForNonUnitTypeTargets ( )
367+ {
368+ var config = new CheckInTimerConfig { DepartmentId = 10 , TimerTargetType = ( int ) CheckInTimerTargetType . Personnel , UnitTypeId = 5 , DurationMinutes = 30 , WarningThresholdMinutes = 5 } ;
369+ _configRepo . Setup ( x => x . SaveOrUpdateAsync ( It . IsAny < CheckInTimerConfig > ( ) , It . IsAny < CancellationToken > ( ) , It . IsAny < bool > ( ) ) )
370+ . ReturnsAsync ( ( CheckInTimerConfig c , CancellationToken ct , bool b ) => c ) ;
371+
372+ var result = await _service . SaveTimerConfigAsync ( config ) ;
373+
374+ result . UnitTypeId . Should ( ) . BeNull ( ) ;
375+ }
376+
277377 #endregion CRUD
278378
379+ #region Per-User Summaries
380+
381+ [ Test ]
382+ public async Task GetUserActiveCallCheckInSummariesAsync_IgnoresNonPersonnelCheckIns ( )
383+ {
384+ var call = new Call { CallId = 1 , DepartmentId = 10 , Priority = 0 , State = ( int ) CallStates . Active , CheckInTimersEnabled = true , LoggedOn = DateTime . UtcNow . AddMinutes ( - 40 ) } ;
385+ _callsService . Setup ( x => x . GetActiveCallsWithCheckInTimersForUserAsync ( "user1" , 10 ) ) . ReturnsAsync ( new List < Call > { call } ) ;
386+ _configRepo . Setup ( x => x . GetByDepartmentIdAsync ( 10 ) ) . ReturnsAsync ( new List < CheckInTimerConfig >
387+ {
388+ new CheckInTimerConfig { TimerTargetType = ( int ) CheckInTimerTargetType . Personnel , DurationMinutes = 30 , WarningThresholdMinutes = 5 , IsEnabled = true }
389+ } ) ;
390+ _overrideRepo . Setup ( x => x . GetMatchingOverridesAsync ( 10 , null , 0 ) ) . ReturnsAsync ( new List < CheckInTimerOverride > ( ) ) ;
391+ // The user's only check-in on the call is an IC check-in — it must not reset
392+ // their personnel timer (same semantics as the per-personnel endpoint).
393+ _recordRepo . Setup ( x => x . GetByCallIdAsync ( 1 ) ) . ReturnsAsync ( new List < CheckInRecord >
394+ {
395+ new CheckInRecord { CheckInRecordId = "r1" , CheckInType = ( int ) CheckInTimerTargetType . IC , UserId = "user1" , Timestamp = DateTime . UtcNow . AddMinutes ( - 2 ) }
396+ } ) ;
397+
398+ var result = await _service . GetUserActiveCallCheckInSummariesAsync ( "user1" , 10 ) ;
399+
400+ result . Should ( ) . HaveCount ( 1 ) ;
401+ result [ 0 ] . LastCheckIn . Should ( ) . BeNull ( ) ;
402+ result [ 0 ] . NeedsCheckIn . Should ( ) . BeTrue ( ) ;
403+ result [ 0 ] . Status . Should ( ) . Be ( "Critical" ) ;
404+ }
405+
406+ #endregion Per-User Summaries
407+
279408 #region ActiveForStates Propagation
280409
281410 [ Test ]
@@ -356,8 +485,8 @@ public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenPersonnelSta
356485 _overrideRepo . Setup ( x => x . GetMatchingOverridesAsync ( 10 , null , 0 ) ) . ReturnsAsync ( new List < CheckInTimerOverride > ( ) ) ;
357486 _recordRepo . Setup ( x => x . GetByCallIdAsync ( 1 ) ) . ReturnsAsync ( new List < CheckInRecord > ( ) ) ;
358487 // User is Responding (2), not On Scene (3)
359- _actionLogsService . Setup ( x => x . GetLastActionLogForUserAsync ( "user1" , null ) )
360- . ReturnsAsync ( new ActionLog { ActionTypeId = ( int ) ActionTypes . Responding } ) ;
488+ _actionLogsService . Setup ( x => x . GetLastActionLogsForDepartmentAsync ( 10 , It . IsAny < bool > ( ) , It . IsAny < bool > ( ) ) )
489+ . ReturnsAsync ( new List < ActionLog > { new ActionLog { UserId = "user1" , ActionTypeId = ( int ) ActionTypes . Responding } } ) ;
361490
362491 var result = await _service . GetActiveTimerStatusesForCallAsync ( call ) ;
363492
@@ -382,8 +511,8 @@ public async Task GetActiveTimerStatusesForCallAsync_IncludesTimer_WhenPersonnel
382511 _overrideRepo . Setup ( x => x . GetMatchingOverridesAsync ( 10 , null , 0 ) ) . ReturnsAsync ( new List < CheckInTimerOverride > ( ) ) ;
383512 _recordRepo . Setup ( x => x . GetByCallIdAsync ( 1 ) ) . ReturnsAsync ( new List < CheckInRecord > ( ) ) ;
384513 // User is On Scene (3) - matches
385- _actionLogsService . Setup ( x => x . GetLastActionLogForUserAsync ( "user1" , null ) )
386- . ReturnsAsync ( new ActionLog { ActionTypeId = ( int ) ActionTypes . OnScene } ) ;
514+ _actionLogsService . Setup ( x => x . GetLastActionLogsForDepartmentAsync ( 10 , It . IsAny < bool > ( ) , It . IsAny < bool > ( ) ) )
515+ . ReturnsAsync ( new List < ActionLog > { new ActionLog { UserId = "user1" , ActionTypeId = ( int ) ActionTypes . OnScene } } ) ;
387516
388517 var result = await _service . GetActiveTimerStatusesForCallAsync ( call ) ;
389518
@@ -431,8 +560,8 @@ public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenUnitStateDoe
431560 _overrideRepo . Setup ( x => x . GetMatchingOverridesAsync ( 10 , null , 0 ) ) . ReturnsAsync ( new List < CheckInTimerOverride > ( ) ) ;
432561 _recordRepo . Setup ( x => x . GetByCallIdAsync ( 1 ) ) . ReturnsAsync ( new List < CheckInRecord > ( ) ) ;
433562 // Unit is Responding (5), not On Scene (6)
434- _unitsService . Setup ( x => x . GetLastUnitStateByUnitIdAsync ( 5 ) )
435- . ReturnsAsync ( new UnitState { State = ( int ) UnitStateTypes . Responding } ) ;
563+ _unitsService . Setup ( x => x . GetAllLatestStatusForUnitsByDepartmentIdAsync ( 10 ) )
564+ . ReturnsAsync ( new List < UnitState > { new UnitState { UnitId = 5 , State = ( int ) UnitStateTypes . Responding } } ) ;
436565
437566 var result = await _service . GetActiveTimerStatusesForCallAsync ( call ) ;
438567
0 commit comments