3333import static org .junit .jupiter .api .Assertions .assertNotNull ;
3434import static org .junit .jupiter .api .Assertions .assertNull ;
3535import static org .junit .jupiter .api .Assertions .assertSame ;
36+ import static org .junit .jupiter .api .Assertions .assertThrows ;
37+ import static org .springframework .http .MediaType .APPLICATION_JSON_VALUE ;
3638
3739import jakarta .servlet .http .HttpServletRequest ;
3840import java .lang .reflect .Method ;
4547import org .springframework .http .MediaType ;
4648import org .springframework .mock .web .MockHttpServletRequest ;
4749import org .springframework .stereotype .Controller ;
50+ import org .springframework .web .HttpMediaTypeNotAcceptableException ;
51+ import org .springframework .web .accept .ContentNegotiationManager ;
4852import org .springframework .web .bind .annotation .GetMapping ;
4953import org .springframework .web .bind .annotation .RequestMapping ;
5054import org .springframework .web .method .HandlerMethod ;
@@ -65,6 +69,10 @@ class CustomRequestMappingHandlerMappingNormalizeTest {
6569 void setUp () throws Exception {
6670 mapping = new CustomRequestMappingHandlerMapping ();
6771 mapping .setApplicationContext (new StaticApplicationContext ());
72+ // Needed so produces conditions evaluate the suffix-forced media type the same way production
73+ // does via SuffixMediaTypeContentNegotiationStrategy (PR-F / #24463).
74+ mapping .setContentNegotiationManager (
75+ new ContentNegotiationManager (new SuffixMediaTypeContentNegotiationStrategy ()));
6876 mapping .afterPropertiesSet ();
6977
7078 Method method = SampleController .class .getMethod ("list" );
@@ -78,6 +86,24 @@ void setUp() throws Exception {
7886 mapping .getMappingForMethod (openapi , SampleController .class ),
7987 new SampleController (),
8088 openapi );
89+
90+ Method queryJson = AnalyticsStyleController .class .getMethod ("queryJson" , String .class );
91+ mapping .registerMapping (
92+ mapping .getMappingForMethod (queryJson , AnalyticsStyleController .class ),
93+ new AnalyticsStyleController (),
94+ queryJson );
95+
96+ Method queryXml = AnalyticsStyleController .class .getMethod ("queryXml" , String .class );
97+ mapping .registerMapping (
98+ mapping .getMappingForMethod (queryXml , AnalyticsStyleController .class ),
99+ new AnalyticsStyleController (),
100+ queryXml );
101+
102+ Method onlyJson = AnalyticsStyleController .class .getMethod ("onlyJson" , String .class );
103+ mapping .registerMapping (
104+ mapping .getMappingForMethod (onlyJson , AnalyticsStyleController .class ),
105+ new AnalyticsStyleController (),
106+ onlyJson );
81107 }
82108
83109 @ Test
@@ -93,6 +119,31 @@ void literalExtensionMappingWinsWhenStrippedPathHasNoHandler() throws Exception
93119 SuffixMediaTypeContentNegotiationStrategy .SUFFIX_MEDIA_TYPE_ATTRIBUTE ));
94120 }
95121
122+ /**
123+ * Regression for analytics-style dual mappings: JSON produces on {@code /query/{id}} plus a
124+ * literal {@code /query/{id}.xml} download handler. Stripping the suffix must not surface the
125+ * JSON handler's 406 and block the literal download mapping (CI failure after #24463).
126+ */
127+ @ Test
128+ void literalDownloadMappingWinsWhenStrippedProducesIsIncompatible () throws Exception {
129+ MockHttpServletRequest request =
130+ request ("/api/analytics/trackedEntities/query/nEenWmSyUEp.xml" );
131+ HandlerMethod handler = mapping .getHandlerInternal (request );
132+ assertNotNull (handler );
133+ assertEquals ("queryXml" , handler .getMethod ().getName ());
134+ assertEquals (
135+ MediaType .APPLICATION_XML ,
136+ request .getAttribute (
137+ SuffixMediaTypeContentNegotiationStrategy .SUFFIX_MEDIA_TYPE_ATTRIBUTE ));
138+ }
139+
140+ @ Test
141+ void rethrowsNotAcceptableWhenNoLiteralFallbackExists () {
142+ MockHttpServletRequest request = request ("/api/analytics/only-json/nEenWmSyUEp.xml" );
143+ assertThrows (
144+ HttpMediaTypeNotAcceptableException .class , () -> mapping .getHandlerInternal (request ));
145+ }
146+
96147 @ ParameterizedTest
97148 @ CsvSource ({
98149 "/api/dataElements.json, application/json" ,
@@ -190,4 +241,31 @@ public String openapi() {
190241 return "openapi" ;
191242 }
192243 }
244+
245+ /**
246+ * Mirrors the analytics download pattern: a generic path with restricted {@code produces} next to
247+ * a literal-suffix download mapping with no produces restriction.
248+ */
249+ @ Controller
250+ @ RequestMapping
251+ static class AnalyticsStyleController {
252+ @ GetMapping (
253+ value = "/api/analytics/trackedEntities/query/{trackedEntityType}" ,
254+ produces = {APPLICATION_JSON_VALUE , "application/javascript" })
255+ public String queryJson (String trackedEntityType ) {
256+ return "json" ;
257+ }
258+
259+ @ GetMapping (value = "/api/analytics/trackedEntities/query/{trackedEntityType}.xml" )
260+ public String queryXml (String trackedEntityType ) {
261+ return "xml" ;
262+ }
263+
264+ @ GetMapping (
265+ value = "/api/analytics/only-json/{id}" ,
266+ produces = {APPLICATION_JSON_VALUE })
267+ public String onlyJson (String id ) {
268+ return "json-only" ;
269+ }
270+ }
193271}
0 commit comments