@@ -87,6 +87,85 @@ public function testRevealPolicyExposesType(): void {
8787 self ::assertSame (3 , $ result ['events ' ][0 ]['typeId ' ]);
8888 }
8989
90+ public function testHrSeesTheTypeUnderTheNeutralPolicy (): void {
91+ // HR record sick leave and can open any request to read its type, so hiding it
92+ // on the who's-off timeline protects nothing — it only leaves the client with
93+ // no type to label the absence with, which used to render as a holiday.
94+ $ this ->config ->method ('getSharedCalendarVisibility ' )->willReturn (ConfigService::VISIBILITY_NEUTRAL );
95+ $ this ->config ->method ('getMaxConcurrentAbsences ' )->willReturn (0 );
96+ $ this ->permission ->method ('isHr ' )->with ('viewer ' )->willReturn (true );
97+ $ this ->requestMapper ->method ('findForEmployeesInRange ' )->willReturn ([
98+ $ this ->request (2 , 'colleague ' ),
99+ ]);
100+
101+ $ result = $ this ->service ->getCoverage (['colleague ' ], '2026-01-01 ' , '2026-01-31 ' , null , 'viewer ' );
102+
103+ self ::assertSame (3 , $ result ['events ' ][0 ]['typeId ' ]);
104+ }
105+
106+ public function testAManagerSeesTheTypeOfTheirOwnReports (): void {
107+ $ this ->config ->method ('getSharedCalendarVisibility ' )->willReturn (ConfigService::VISIBILITY_NEUTRAL );
108+ $ this ->config ->method ('getMaxConcurrentAbsences ' )->willReturn (0 );
109+ $ this ->permission ->method ('isHr ' )->willReturn (false );
110+ $ this ->managerResolver ->method ('getDirectReports ' )->with ('boss ' )->willReturn (['report ' ]);
111+ $ this ->requestMapper ->method ('findForEmployeesInRange ' )->willReturn ([
112+ $ this ->request (2 , 'report ' ),
113+ $ this ->request (3 , 'somebody-elses-report ' ),
114+ ]);
115+
116+ $ result = $ this ->service ->getCoverage (['report ' , 'somebody-elses-report ' ], '2026-01-01 ' , '2026-01-31 ' , null , 'boss ' );
117+ $ byUid = [];
118+ foreach ($ result ['events ' ] as $ event ) {
119+ $ byUid [$ event ['employeeUid ' ]] = $ event ['typeId ' ];
120+ }
121+
122+ self ::assertSame (3 , $ byUid ['report ' ], 'Their own report, whose requests they decide ' );
123+ self ::assertNull ($ byUid ['somebody-elses-report ' ], 'Not their report — still withheld ' );
124+ }
125+
126+ public function testAPeerStillLearnsNothingAboutAColleague (): void {
127+ // The protection the policy exists for: a plain colleague sees that somebody is
128+ // away and nothing about why.
129+ $ this ->config ->method ('getSharedCalendarVisibility ' )->willReturn (ConfigService::VISIBILITY_NEUTRAL );
130+ $ this ->config ->method ('getMaxConcurrentAbsences ' )->willReturn (0 );
131+ $ this ->permission ->method ('isHr ' )->willReturn (false );
132+ $ this ->managerResolver ->method ('getDirectReports ' )->willReturn ([]);
133+ $ this ->requestMapper ->method ('findForEmployeesInRange ' )->willReturn ([
134+ $ this ->request (2 , 'colleague ' ),
135+ ]);
136+
137+ $ result = $ this ->service ->getCoverage (['colleague ' ], '2026-01-01 ' , '2026-01-31 ' , null , 'peer ' );
138+
139+ self ::assertNull ($ result ['events ' ][0 ]['typeId ' ]);
140+ }
141+
142+ public function testAnAnonymousCallerLearnsNothing (): void {
143+ // No viewer to check permissions against, so fail closed rather than reveal.
144+ $ this ->config ->method ('getSharedCalendarVisibility ' )->willReturn (ConfigService::VISIBILITY_NEUTRAL );
145+ $ this ->config ->method ('getMaxConcurrentAbsences ' )->willReturn (0 );
146+ $ this ->permission ->expects (self ::never ())->method ('isHr ' );
147+ $ this ->requestMapper ->method ('findForEmployeesInRange ' )->willReturn ([
148+ $ this ->request (2 , 'colleague ' ),
149+ ]);
150+
151+ $ result = $ this ->service ->getCoverage (['colleague ' ], '2026-01-01 ' , '2026-01-31 ' , null , null );
152+
153+ self ::assertNull ($ result ['events ' ][0 ]['typeId ' ]);
154+ }
155+
156+ public function testTheRevealPolicyDoesNotBotherResolvingPermissions (): void {
157+ // Everyone sees every type anyway, so the group and manager lookups are waste.
158+ $ this ->config ->method ('getSharedCalendarVisibility ' )->willReturn (ConfigService::VISIBILITY_REVEAL );
159+ $ this ->config ->method ('getMaxConcurrentAbsences ' )->willReturn (0 );
160+ $ this ->permission ->expects (self ::never ())->method ('isHr ' );
161+ $ this ->managerResolver ->expects (self ::never ())->method ('getDirectReports ' );
162+ $ this ->requestMapper ->method ('findForEmployeesInRange ' )->willReturn ([
163+ $ this ->request (2 , 'colleague ' ),
164+ ]);
165+
166+ self ::assertSame (3 , $ this ->service ->getCoverage (['colleague ' ], '2026-01-01 ' , '2026-01-31 ' , null , 'viewer ' )['events ' ][0 ]['typeId ' ]);
167+ }
168+
90169 public function testRejectsInvalidRange (): void {
91170 $ this ->expectException (ValidationException::class);
92171 $ this ->service ->getCoverage (['viewer ' ], '2026-13-99 ' , '2026-01-31 ' , null , 'viewer ' );
0 commit comments