@@ -13,45 +13,52 @@ import {
1313
1414const app = v . fn ( { use : [ http ] } ) ;
1515
16- describe ( "http.err - status on declared errors" , ( ) => {
17- it ( "stashes status without polluting enumerable fields" , ( ) => {
18- const decl = err ( 401 , { attempts : v . number ( ) } ) ;
16+ describe ( "http.err - status + message on declared errors" , ( ) => {
17+ it ( "stashes status and message without polluting enumerable fields" , ( ) => {
18+ const decl = err ( 401 , "Invalid credentials" , { attempts : v . number ( ) } ) ;
1919 expect ( statusOf ( decl ) ) . toBe ( 401 ) ;
2020 expect ( Object . keys ( decl ) ) . toEqual ( [ "attempts" ] ) ;
2121 expect ( ( decl as Record < symbol , unknown > ) [ kHttpErr ] ) . toEqual ( {
2222 status : 401 ,
23+ message : "Invalid credentials" ,
2324 } ) ;
2425 } ) ;
2526
26- it ( "status -only declarations stay empty payloads" , ( ) => {
27- const decl = err ( 410 ) ;
27+ it ( "message -only declarations stay empty payloads" , ( ) => {
28+ const decl = err ( 410 , "Gone" ) ;
2829 expect ( statusOf ( decl ) ) . toBe ( 410 ) ;
2930 expect ( Object . keys ( decl ) ) . toEqual ( [ ] ) ;
31+ expect ( ( decl as Record < symbol , unknown > ) [ kHttpErr ] ) . toEqual ( {
32+ status : 410 ,
33+ message : "Gone" ,
34+ } ) ;
3035 } ) ;
3136
3237 it ( "refuses non-HTTP status numbers" , ( ) => {
33- expect ( ( ) => err ( 99 ) ) . toThrow ( / h t t p \. e r r \. s t a t u s / ) ;
34- expect ( ( ) => err ( 600 ) ) . toThrow ( / h t t p \. e r r \. s t a t u s / ) ;
35- expect ( ( ) => err ( 401.5 ) ) . toThrow ( / h t t p \. e r r \. s t a t u s / ) ;
38+ expect ( ( ) => err ( 99 , "nope" ) ) . toThrow ( / h t t p \. e r r \. s t a t u s / ) ;
39+ expect ( ( ) => err ( 600 , "nope" ) ) . toThrow ( / h t t p \. e r r \. s t a t u s / ) ;
40+ expect ( ( ) => err ( 401.5 , "nope" ) ) . toThrow ( / h t t p \. e r r \. s t a t u s / ) ;
3641 } ) ;
3742
3843 it ( "copies the data shape so shared schemas do not share meta" , ( ) => {
3944 const shared = { attempts : v . number ( ) } ;
40- const a = err ( 401 , shared ) ;
41- const b = err ( 403 , shared ) ;
45+ const a = err ( 401 , "a" , shared ) ;
46+ const b = err ( 403 , "b" , shared ) ;
4247 expect ( statusOf ( a ) ) . toBe ( 401 ) ;
4348 expect ( statusOf ( b ) ) . toBe ( 403 ) ;
4449 expect ( statusOf ( shared ) ) . toBeUndefined ( ) ;
4550 } ) ;
4651
47- it ( "c.error still validates payload; status never enters data" , ( ) => {
52+ it ( "c.error still validates payload; status and message never enter data" , ( ) => {
4853 const signIn = app . fn (
4954 "httpt.err.sign_in" ,
5055 {
5156 input : { kind : v . string ( ) } ,
5257 errors : {
53- invalid_credentials : err ( 401 , { attempts : v . number ( ) } ) ,
54- gone : err ( 410 ) ,
58+ invalid_credentials : err ( 401 , "Invalid credentials" , {
59+ attempts : v . number ( ) ,
60+ } ) ,
61+ gone : err ( 410 , "Gone" ) ,
5562 } ,
5663 } ,
5764 ( c ) => {
@@ -67,6 +74,7 @@ describe("http.err - status on declared errors", () => {
6774 expect ( bad . error . tag ) . toBe ( "invalid_credentials" ) ;
6875 expect ( bad . error . data ) . toEqual ( { attempts : 3 } ) ;
6976 expect ( bad . error . status ) . toBe ( 401 ) ;
77+ expect ( bad . error . message ) . toBe ( "Invalid credentials" ) ;
7078 if ( bad . error . tag === "invalid_credentials" ) {
7179 expectTypeOf ( bad . error . data ) . toEqualTypeOf < {
7280 readonly attempts : number ;
@@ -80,12 +88,17 @@ describe("http.err - status on declared errors", () => {
8088 expect ( gone . error . tag ) . toBe ( "gone" ) ;
8189 expect ( gone . error . data ) . toEqual ( { } ) ;
8290 expect ( gone . error . status ) . toBe ( 410 ) ;
91+ expect ( gone . error . message ) . toBe ( "Gone" ) ;
8392 }
8493
8594 expect ( ( ) =>
8695 app . fn (
8796 "httpt.err.liar" ,
88- { errors : { oops : err ( 400 , { code : v . number ( ) } ) } } ,
97+ {
98+ errors : {
99+ oops : err ( 400 , "Oops" , { code : v . number ( ) } ) ,
100+ } ,
101+ } ,
89102 ( c ) => {
90103 throw c . error ( "oops" , { code : "nope" } as never ) ;
91104 } ,
@@ -98,7 +111,7 @@ describe("http.err - status on declared errors", () => {
98111 "httpt.err.guard" ,
99112 {
100113 errors : {
101- denied : err ( 403 ) ,
114+ denied : err ( 403 , "Denied" ) ,
102115 plain : { reason : v . string ( ) } ,
103116 } ,
104117 } ,
@@ -118,8 +131,8 @@ describe("http.err - status on declared errors", () => {
118131 "httpt.err.endpoint" ,
119132 {
120133 errors : {
121- unauthorized : err ( 401 , { attempts : v . number ( ) } ) ,
122- conflict : err ( 409 ) ,
134+ unauthorized : err ( 401 , "Unauthorized" , { attempts : v . number ( ) } ) ,
135+ conflict : err ( 409 , "Conflict" ) ,
123136 } ,
124137 } ,
125138 ( c ) => {
@@ -149,8 +162,8 @@ describe("http.err - status on declared errors", () => {
149162 } ) ;
150163
151164 it ( "http.err is re-exported on the http module" , ( ) => {
152- expect ( http . err ( 418 ) ) . toBeDefined ( ) ;
153- expect ( http . statusOf ( http . err ( 418 ) ) ) . toBe ( 418 ) ;
165+ expect ( http . err ( 418 , "I'm a teapot" ) ) . toBeDefined ( ) ;
166+ expect ( http . statusOf ( http . err ( 418 , "I'm a teapot" ) ) ) . toBe ( 418 ) ;
154167 } ) ;
155168} ) ;
156169
@@ -216,10 +229,10 @@ describe("encodeError + createHandler JSON bodies", () => {
216229 } ) ;
217230 } ) ;
218231
219- it ( "createHandler returns declared status JSON for FnError" , async ( ) => {
232+ it ( "createHandler returns declared status + message JSON for FnError" , async ( ) => {
220233 const deny = app . fn (
221234 "httpt.err.deny" ,
222- { errors : { denied : err ( 403 ) } } ,
235+ { errors : { denied : err ( 403 , "Denied" ) } } ,
223236 ( c ) => {
224237 throw c . error ( "denied" ) ;
225238 } ,
@@ -235,6 +248,140 @@ describe("encodeError + createHandler JSON bodies", () => {
235248 data : { } ,
236249 trail : [ "httpt.err.deny" ] ,
237250 status : 403 ,
251+ message : "Denied" ,
252+ } ) ;
253+ } ) ;
254+
255+ it ( "encodeError rewrites FnError message by tag and keeps originalMessage" , ( ) => {
256+ const stamped = new FnError ( "denied" , { } , "f" , 403 , "Denied" ) ;
257+ expect (
258+ encodeError ( stamped , {
259+ messages : { denied : "Refusé" } ,
260+ } ) ,
261+ ) . toEqual ( {
262+ status : 403 ,
263+ body : {
264+ name : "FnError" ,
265+ tag : "denied" ,
266+ data : { } ,
267+ trail : [ "f" ] ,
268+ status : 403 ,
269+ message : "Refusé" ,
270+ originalMessage : "Denied" ,
271+ } ,
272+ } ) ;
273+ } ) ;
274+
275+ it ( "encodeError message fn can interpolate data" , ( ) => {
276+ const stamped = new FnError (
277+ "rate_limited" ,
278+ { retryAfter : 30 } ,
279+ "f" ,
280+ 429 ,
281+ "Too many attempts" ,
282+ ) ;
283+ expect (
284+ encodeError ( stamped , {
285+ message : ( error ) =>
286+ error . tag === "rate_limited"
287+ ? `Réessayez dans ${ ( error . data as { retryAfter : number } ) . retryAfter } s`
288+ : error . message ,
289+ } ) ?. body ,
290+ ) . toMatchObject ( {
291+ message : "Réessayez dans 30s" ,
292+ originalMessage : "Too many attempts" ,
293+ } ) ;
294+ } ) ;
295+
296+ it ( "encodeError messages fn can pick a locale from the request" , ( ) => {
297+ const stamped = new FnError ( "denied" , { } , "f" , 403 , "Denied" ) ;
298+ const request = new Request ( "http://x.test/" , {
299+ headers : { "Accept-Language" : "fr" } ,
300+ } ) ;
301+ expect (
302+ encodeError ( stamped , {
303+ request,
304+ messages : ( req ) => {
305+ const locale = req ?. headers . get ( "Accept-Language" ) ?? "en" ;
306+ return {
307+ en : { denied : "Denied" } ,
308+ fr : { denied : "Refusé" } ,
309+ } [ locale ] ;
310+ } ,
311+ } ) ?. body ,
312+ ) . toMatchObject ( {
313+ message : "Refusé" ,
314+ originalMessage : "Denied" ,
315+ } ) ;
316+ } ) ;
317+
318+ it ( "createHandler message fn receives the request" , async ( ) => {
319+ const deny = app . fn (
320+ "httpt.err.deny_req" ,
321+ { errors : { denied : err ( 403 , "Denied" ) } } ,
322+ ( c ) => {
323+ throw c . error ( "denied" ) ;
324+ } ,
325+ ) ;
326+ const fetch = http . createHandler (
327+ ( ) => {
328+ throw deny ( ) ;
329+ } ,
330+ {
331+ message : ( error , request ) => {
332+ if ( request ?. headers . get ( "Accept-Language" ) === "fr" ) {
333+ return "Refusé" ;
334+ }
335+ return error . message ;
336+ } ,
337+ } ,
338+ ) ;
339+ const response = await fetch (
340+ new Request ( "http://x.test/" , {
341+ headers : { "Accept-Language" : "fr" } ,
342+ } ) ,
343+ ) ;
344+ expect ( response . status ) . toBe ( 403 ) ;
345+ await expect ( response . json ( ) ) . resolves . toMatchObject ( {
346+ message : "Refusé" ,
347+ originalMessage : "Denied" ,
348+ } ) ;
349+ } ) ;
350+
351+ it ( "encodeError leaves message alone when no override matches" , ( ) => {
352+ const stamped = new FnError ( "denied" , { } , "f" , 403 , "Denied" ) ;
353+ expect ( encodeError ( stamped , { messages : { other : "x" } } ) ?. body ) . toEqual ( {
354+ name : "FnError" ,
355+ tag : "denied" ,
356+ data : { } ,
357+ trail : [ "f" ] ,
358+ status : 403 ,
359+ message : "Denied" ,
360+ } ) ;
361+ } ) ;
362+
363+ it ( "createHandler applies messages overrides from options" , async ( ) => {
364+ const deny = app . fn (
365+ "httpt.err.deny_i18n" ,
366+ { errors : { denied : err ( 403 , "Denied" ) } } ,
367+ ( c ) => {
368+ throw c . error ( "denied" ) ;
369+ } ,
370+ ) ;
371+ const fetch = http . createHandler (
372+ ( ) => {
373+ throw deny ( ) ;
374+ } ,
375+ {
376+ messages : { denied : "Refusé" } ,
377+ } ,
378+ ) ;
379+ const response = await fetch ( new Request ( "http://x.test/" ) ) ;
380+ expect ( response . status ) . toBe ( 403 ) ;
381+ await expect ( response . json ( ) ) . resolves . toMatchObject ( {
382+ tag : "denied" ,
383+ message : "Refusé" ,
384+ originalMessage : "Denied" ,
238385 } ) ;
239386 } ) ;
240387} ) ;
0 commit comments