webpipe-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1139 @@
1
+ # Test App
2
+
3
+ GET /hello/:world
4
+ |> jq: `{ world: .params.world }`
5
+ |> handlebars: `<p>hello, {{world}}</p>`
6
+
7
+ describe "hello, world"
8
+ it "calls the route"
9
+ when calling GET /hello/world
10
+ then status is 200
11
+ and output equals `<p>hello, world</p>`
12
+
13
+ GET /lua/:id/example
14
+ |> lua: `
15
+ local id = request.params.id
16
+ local name = request.query.name
17
+ return {
18
+ message = "Hello from Lua!",
19
+ id = id,
20
+ name = name
21
+ }
22
+ `
23
+ describe "lua"
24
+ it "calls the route"
25
+ when calling GET /lua/123/example?name=example
26
+ then status is 200
27
+ and output equals `{
28
+ "message": "Hello from Lua!",
29
+ "id": 123,
30
+ "name": "example"
31
+ }`
32
+
33
+ ## Config
34
+ config pg {
35
+ host: $WP_PG_HOST || "localhost"
36
+ port: $WP_PG_PORT || "5432"
37
+ database: $WP_PG_DATABASE || "express-test"
38
+ user: $WP_PG_USER || "postgres"
39
+ password: $WP_PG_PASSWORD || "postgres"
40
+ ssl: false
41
+ initialPoolSize: 10
42
+ maxPoolSize: 20
43
+ }
44
+
45
+ config auth {
46
+ sessionTtl: 604800
47
+ cookieName: "wp_session"
48
+ cookieSecure: false
49
+ cookieHttpOnly: true
50
+ cookieSameSite: "Lax"
51
+ cookiePath: "/"
52
+ }
53
+
54
+ ## Tests
55
+
56
+ GET /test-lua-env
57
+ |> lua: `
58
+ local env = getEnv("WP_PG_USER")
59
+ return {
60
+ env = env
61
+ }
62
+ `
63
+
64
+ pipeline getTeams =
65
+ |> jq: `{ sqlParams: [.params.id] }`
66
+ |> pg: `SELECT * FROM teams WHERE id = $1`
67
+
68
+ GET /hello
69
+ |> jq: `{ hello: "world" }`
70
+
71
+ GET /page/:id
72
+ |> pipeline: getTeams
73
+ |> jq: `{ team: .data.rows[0] }`
74
+
75
+ pg teamsQuery = `SELECT * FROM teams`
76
+
77
+ GET /teams
78
+ |> jq: `{ sqlParams: [] }`
79
+ |> pg: teamsQuery
80
+
81
+ # Testing standalone variables
82
+ describe "teamsQuery variable"
83
+ with mock pg.teamsQuery returning `{
84
+ "rows": [
85
+ { "id": "1", "name": "Platform" },
86
+ { "id": "2", "name": "Growth" },
87
+ { "id": "3", "name": "Security" }
88
+ ]
89
+ }`
90
+
91
+ it "returns all teams"
92
+ when executing variable pg teamsQuery
93
+ with input `{ "sqlParams": [] }`
94
+ then output equals `{
95
+ "rows": [
96
+ { "id": "1", "name": "Platform" },
97
+ { "id": "2", "name": "Growth" },
98
+ { "id": "3", "name": "Security" }
99
+ ]
100
+ }`
101
+
102
+ # Testing a pipeline directly
103
+ describe "getTeams pipeline"
104
+ with mock pg returning `{
105
+ "rows": [{ "id": "2", "name": "Growth", "created_at": "2024-01-20" }]
106
+ }`
107
+
108
+ it "transforms params and queries database"
109
+ when executing pipeline getTeams
110
+ with input `{ "params": { "id": "2" } }`
111
+ then output equals `{
112
+ "rows": [{ "id": "2", "name": "Growth", "created_at": "2024-01-20" }]
113
+ }`
114
+
115
+ it "handles string id parameter"
116
+ when executing pipeline getTeams
117
+ with input `{ "params": { "id": 42 } }`
118
+ and mock pg returning `{ "rows": [{ "id": 42, "name": "Marketing" }] }`
119
+ then output equals `{
120
+ "rows": [{ "id": 42, "name": "Marketing" }]
121
+ }`
122
+
123
+ it "supports jq equals on output subpath"
124
+ when executing pipeline getTeams
125
+ with input `{ "params": { "id": 42 } }`
126
+ and mock pg returning `{ "rows": [{ "id": 42, "name": "Marketing" }] }`
127
+ then output `.rows[0].id` equals 42
128
+
129
+ describe "test calling route"
130
+ it "calls the route"
131
+ when calling GET /hello
132
+ then status is 200
133
+ and output equals `{
134
+ "hello": "world"
135
+ }`
136
+
137
+ describe "jq assertions"
138
+ it "supports output contains for partial JSON"
139
+ when calling GET /hello
140
+ then output contains `{ "hello": "world" }`
141
+
142
+ it "supports output matches for HTML"
143
+ when calling GET /hello/world
144
+ then output matches `^<p>hello, .*</p>$`
145
+
146
+ it "supports status ranges"
147
+ when calling GET /hello
148
+ then status in 200..299
149
+
150
+ it "supports jq filter with map"
151
+ when executing pipeline getTeams
152
+ with input `{ "params": { "id": 2 } }`
153
+ and mock pg returning `{ "rows": [{ "id": 2, "name": "Growth" }, { "id": 3, "name": "Security" }] }`
154
+ then output `.rows | map(.id)` equals `[2, 3]`
155
+
156
+ GET /test
157
+ |> jq: `.`
158
+
159
+ GET /test2
160
+ |> lua: `return request`
161
+
162
+ GET /lua-cpu
163
+ |> lua: `
164
+ -- CPU-heavy Lua route for benchmarking (small response)
165
+ local total = 0
166
+ local sumsq = 0
167
+ for i = 1, 100000 do
168
+ total = total + i
169
+ sumsq = sumsq + (i * i)
170
+ end
171
+
172
+ local function is_prime(n)
173
+ if n < 2 then return false end
174
+ local limit = math.floor(math.sqrt(n))
175
+ for d = 2, limit do
176
+ if (n % d) == 0 then return false end
177
+ end
178
+ return true
179
+ end
180
+
181
+ local primes = 0
182
+ for i = 2, 2000 do
183
+ if is_prime(i) then primes = primes + 1 end
184
+ end
185
+
186
+ return {
187
+ note = "CPU-bound Lua result (sum/sumsq/primes)",
188
+ sum = total,
189
+ sumsq = sumsq,
190
+ primesUpTo2000 = primes
191
+ }
192
+ `
193
+
194
+ GET /test3
195
+ |> jq: `{message: "Hello World", status: "success"}`
196
+ |> result
197
+ ok(200):
198
+ |> jq: `{
199
+ success: true,
200
+ data: .message,
201
+ timestamp: now
202
+ }`
203
+ default(500):
204
+ |> jq: `{
205
+ error: "Something went wrong",
206
+ timestamp: now
207
+ }`
208
+
209
+ GET /test4
210
+ |> jq: `{
211
+ errors: [
212
+ {
213
+ type: "validationError",
214
+ field: "email",
215
+ message: "Email is required",
216
+ code: "FIELD_REQUIRED"
217
+ }
218
+ ]
219
+ }`
220
+ |> result
221
+ ok(200):
222
+ |> jq: `{success: true}`
223
+ validationError(400):
224
+ |> jq: `{
225
+ error: "Validation failed",
226
+ field: .errors[0].field,
227
+ message: .errors[0].message,
228
+ code: .errors[0].code
229
+ }`
230
+ default(500):
231
+ |> jq: `{error: "Internal server error"}`
232
+
233
+ GET /test5
234
+ |> jq: `{
235
+ errors: [
236
+ {
237
+ type: "authRequired",
238
+ header: "Authorization",
239
+ expected: "Bearer <token>",
240
+ provided: null
241
+ }
242
+ ]
243
+ }`
244
+ |> result
245
+ ok(200):
246
+ |> jq: `{success: true, data: .result}`
247
+ authRequired(401):
248
+ |> jq: `{
249
+ error: "Authentication required",
250
+ header: .errors[0].header,
251
+ expected: .errors[0].expected,
252
+ provided: .errors[0].provided
253
+ }`
254
+ default(500):
255
+ |> jq: `{error: "Internal server error"}`
256
+
257
+ GET /test6
258
+ |> jq: `{
259
+ errors: [
260
+ {
261
+ type: "unknownError",
262
+ message: "This is a custom error type not handled explicitly"
263
+ }
264
+ ]
265
+ }`
266
+ |> result
267
+ ok(200):
268
+ |> jq: `{success: true}`
269
+ validationError(400):
270
+ |> jq: `{error: "Validation error"}`
271
+ default(500):
272
+ |> jq: `{
273
+ error: "Unhandled error occurred",
274
+ type: .errors[0].type,
275
+ message: .errors[0].message
276
+ }`
277
+
278
+ GET /test7
279
+ |> lua: `
280
+ -- Test executeSql function in Lua
281
+ local result, err = executeSql("SELECT * FROM teams LIMIT 5")
282
+
283
+ if err then
284
+ return {
285
+ error = "Database error: " .. err,
286
+ sql = "SELECT * FROM teams LIMIT 5"
287
+ }
288
+ end
289
+
290
+ return {
291
+ message = "Lua executeSql test successful",
292
+ sql = "SELECT * FROM teams LIMIT 5",
293
+ data = result,
294
+ luaVersion = _VERSION
295
+ }
296
+ `
297
+
298
+
299
+ GET /test-sql-error
300
+ |> jq: `{ sqlParams: [] }`
301
+ |> pg: `SELECT * FROM nonexistent_table`
302
+ |> result
303
+ ok(200):
304
+ |> jq: `{success: true, data: .data}`
305
+ sqlError(500):
306
+ |> jq: `{
307
+ error: "Database error",
308
+ sqlstate: .errors[0].sqlstate,
309
+ message: .errors[0].message,
310
+ query: .errors[0].query
311
+ }`
312
+ default(500):
313
+ |> jq: `{error: "Internal server error"}`
314
+
315
+ POST /users
316
+ |> jq: `{
317
+ method: .method,
318
+ name: .body.name,
319
+ email: .body.email,
320
+ action: "create"
321
+ }`
322
+
323
+ PUT /users/:id
324
+ |> jq: `{
325
+ method: .method,
326
+ id: (.params.id | tonumber),
327
+ name: .body.name,
328
+ email: .body.email,
329
+ action: "update"
330
+ }`
331
+
332
+ PATCH /users/:id
333
+ |> jq: `{
334
+ method: .method,
335
+ id: (.params.id | tonumber),
336
+ body: .body,
337
+ action: "partial_update"
338
+ }`
339
+
340
+ POST /test-body
341
+ |> jq: `{
342
+ method: .method,
343
+ body: .body,
344
+ hasBody: (.body != null)
345
+ }`
346
+
347
+ PUT /test-body
348
+ |> jq: `{
349
+ method: .method,
350
+ body: .body,
351
+ hasBody: (.body != null)
352
+ }`
353
+
354
+ PATCH /test-body
355
+ |> jq: `{
356
+ method: .method,
357
+ body: .body,
358
+ hasBody: (.body != null)
359
+ }`
360
+
361
+ GET /hello-handlebars
362
+ |> jq: `{ name: "World", message: "Hello from handlebars!" }`
363
+ |> handlebars: `
364
+ <html>
365
+ <head>
366
+ <title>{{message}}</title>
367
+ </head>
368
+ <body>
369
+ <h1>{{message}}</h1>
370
+ <p>Hello, {{name}}!</p>
371
+ </body>
372
+ </html>
373
+ `
374
+
375
+ GET /handlebars-error-test
376
+ |> jq: `{ invalid: "data" }`
377
+ |> handlebars: `{{syntax_error`
378
+
379
+ handlebars cardPartial = `
380
+ <div class="card">
381
+ <h3>{{title}}</h3>
382
+ <p>{{description}}</p>
383
+ </div>
384
+ `
385
+
386
+ handlebars headerPartial = `
387
+ <header>
388
+ <h1>{{siteName}}</h1>
389
+ <nav>{{>navPartial}}</nav>
390
+ </header>
391
+ `
392
+
393
+ handlebars navPartial = `
394
+ <ul>
395
+ <li><a href="/">Home</a></li>
396
+ <li><a href="/about">About</a></li>
397
+ </ul>
398
+ `
399
+
400
+ GET /test-partials
401
+ |> jq: `{
402
+ title: "Welcome",
403
+ description: "This is a test card",
404
+ siteName: "My Website"
405
+ }`
406
+ |> handlebars: `
407
+ <html>
408
+ <head>
409
+ <title>{{siteName}}</title>
410
+ </head>
411
+ <body>
412
+ {{>headerPartial}}
413
+ <main>
414
+ {{>cardPartial}}
415
+ </main>
416
+ </body>
417
+ </html>
418
+ `
419
+
420
+ GET /test-missing-partial
421
+ |> jq: `{ title: "Test" }`
422
+ |> handlebars: `
423
+ <html>
424
+ <body>
425
+ {{>nonexistentPartial}}
426
+ </body>
427
+ </html>
428
+ `
429
+
430
+ GET /test-sql-error-handlebars
431
+ |> jq: `{ sqlParams: [] }`
432
+ |> pg: `SELECT * FROM nonexistent_table`
433
+ |> result
434
+ ok(200):
435
+ |> jq: `{success: true, data: .data}`
436
+ sqlError(500):
437
+ |> jq: `{
438
+ error: "Database error",
439
+ sqlstate: .errors[0].sqlstate,
440
+ message: .errors[0].message,
441
+ query: .errors[0].query
442
+ }`
443
+ |> handlebars: `{{error}}`
444
+ default(500):
445
+ |> jq: `{error: "Internal server error"}`
446
+ |> handlebars: `{{error}}`
447
+
448
+ GET /cookies
449
+ |> jq: `{
450
+ message: "Cookie test response",
451
+ cookies: .cookies,
452
+ setCookies: [
453
+ "sessionId=abc123; HttpOnly; Secure; Max-Age=3600",
454
+ "userId=john; Max-Age=86400",
455
+ "theme=dark; Path=/"
456
+ ]
457
+ }`
458
+
459
+ GET /auth/status
460
+ |> auth: "optional"
461
+ |> jq: `{
462
+ authenticated: (.user != null),
463
+ user: .user,
464
+ message: if .user then "User is authenticated" else "User is not authenticated" end
465
+ }`
466
+
467
+ GET /debug/users
468
+ |> jq: `{ sqlParams: [] }`
469
+ |> pg: `SELECT id, login, email, type, status, created_at FROM users LIMIT 5`
470
+ |> jq: `{
471
+ message: "Users in database",
472
+ users: .data.rows,
473
+ count: (.data.rows | length)
474
+ }`
475
+
476
+ GET /debug/test-user
477
+ |> jq: `{ sqlParams: ["admin"] }`
478
+ |> pg: `SELECT id, login, password_hash, email, type, status FROM users WHERE login = $1 AND status = 'active'`
479
+ |> jq: `{
480
+ message: "Test user lookup for 'admin'",
481
+ found: (.data.rows | length > 0),
482
+ user: if (.data.rows | length > 0) then .data.rows[0] else null end
483
+ }`
484
+
485
+ GET /debug/users-schema
486
+ |> jq: `{ sqlParams: [] }`
487
+ |> pg: `SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'users' ORDER BY ordinal_position`
488
+ |> jq: `{
489
+ message: "Users table schema",
490
+ columns: .data.rows
491
+ }`
492
+
493
+ POST /debug/test-hash
494
+ |> auth: "register"
495
+ |> jq: `{
496
+ message: "Test hash generation (this will fail but show us what's happening)",
497
+ body: .body,
498
+ errors: .errors
499
+ }`
500
+
501
+ config cache {
502
+ enabled: true
503
+ defaultTtl: 60
504
+ maxCacheSize: 10485760
505
+ }
506
+
507
+ GET /cache-test
508
+ |> cache: `
509
+ ttl: 10
510
+ enabled: true
511
+ `
512
+ |> jq: `{
513
+ message: "Hello from cache test!",
514
+ timestamp: now,
515
+ random: (now % 1000)
516
+ }`
517
+
518
+ GET /no-cache-test
519
+ |> jq: `{
520
+ message: "No cache here",
521
+ timestamp: now,
522
+ random: (now % 1000)
523
+ }`
524
+
525
+ GET /slow-cached-test
526
+ |> cache: `
527
+ ttl: 30
528
+ enabled: true
529
+ `
530
+ |> jq: `{ sqlParams: [] }`
531
+ |> pg: `SELECT pg_sleep(0.25), 'Slow database operation completed!' as message, now() as timestamp`
532
+ |> jq: `{
533
+ message: .data.rows[0].message,
534
+ timestamp: .data.rows[0].timestamp,
535
+ cached: true,
536
+ note: "This query sleeps for 250ms - should be fast on cache hit!"
537
+ }`
538
+
539
+ GET /slow-uncached-test
540
+ |> jq: `{ sqlParams: [] }`
541
+ |> pg: `SELECT pg_sleep(0.25), 'Slow database operation completed!' as message, now() as timestamp`
542
+ |> jq: `{
543
+ message: .data.rows[0].message,
544
+ timestamp: .data.rows[0].timestamp,
545
+ cached: false,
546
+ note: "This query sleeps for 250ms - always slow!"
547
+ }`
548
+
549
+ GET /very-slow-cached-test
550
+ |> cache: `
551
+ ttl: 60
552
+ enabled: true
553
+ `
554
+ |> jq: `{ sqlParams: [] }`
555
+ |> pg: `SELECT pg_sleep(1.0), 'Very slow database operation completed!' as message, now() as timestamp, random() as random_value`
556
+ |> jq: `{
557
+ message: .data.rows[0].message,
558
+ timestamp: .data.rows[0].timestamp,
559
+ random_value: .data.rows[0].random_value,
560
+ cached: true,
561
+ note: "This query sleeps for 1 second - cache makes a huge difference!"
562
+ }`
563
+
564
+ GET /user/:id/profile
565
+ |> cache: `
566
+ keyTemplate: user-profile-{params.id}
567
+ ttl: 30
568
+ enabled: true
569
+ `
570
+ |> jq: `{ sqlParams: [.params.id] }`
571
+ |> pg: `SELECT pg_sleep(0.5), $1 as user_id, 'User profile data' as profile_type, now() as fetched_at`
572
+ |> jq: `{
573
+ user_id: .data.rows[0].user_id,
574
+ profile_type: .data.rows[0].profile_type,
575
+ fetched_at: .data.rows[0].fetched_at,
576
+ cache_key_used: "user-profile-" + (.originalRequest.params.id | tostring),
577
+ note: "Cache key includes user ID from URL parameter"
578
+ }`
579
+
580
+ GET /api/search
581
+ |> log: `level: debug, includeBody: true, includeHeaders: true`
582
+ |> cache: `
583
+ keyTemplate: search-{query.q}-{query.category}
584
+ ttl: 15
585
+ enabled: true
586
+ `
587
+ |> jq: `{
588
+ sqlParams: [.query.q // "default", .query.category // "all"],
589
+ search_term: .query.q,
590
+ search_category: .query.category
591
+ }`
592
+ |> pg: `SELECT pg_sleep(0.3), $1 as term, $2 as category, 'Search results' as result_type, now() as searched_at`
593
+ |> jq: `{
594
+ search_term: .data.rows[0].term,
595
+ category: .data.rows[0].category,
596
+ result_type: .data.rows[0].result_type,
597
+ searched_at: .data.rows[0].searched_at,
598
+ cache_key_used: "search-" + (.originalRequest.query.q | tostring) + "-" + (.originalRequest.query.category | tostring),
599
+ note: "Cache key varies by search term and category query parameters"
600
+ }`
601
+
602
+ config log {
603
+ enabled: true
604
+ format: "json"
605
+ level: "debug"
606
+ includeBody: false
607
+ includeHeaders: true
608
+ maxBodySize: 1024
609
+ timestamp: true
610
+ }
611
+
612
+
613
+ GET /api/users/:id
614
+ |> log: `level: debug, includeBody: true, includeHeaders: true`
615
+ |> pg: `SELECT * FROM users WHERE id = $1`
616
+
617
+ ## Todos App
618
+
619
+ # Default partials to avoid missing-partial errors and enable overrides
620
+ handlebars title = `Default Title`
621
+ handlebars headExtras = ``
622
+ handlebars content = `Default content`
623
+ handlebars footerScripts = ``
624
+ handlebars pageTitle = `Page Title`
625
+ handlebars pageContent = ``
626
+ handlebars authHeader = ``
627
+
628
+ handlebars baseLayout = `
629
+ <!DOCTYPE html>
630
+ <html lang="en">
631
+ <head>
632
+ <meta charset="UTF-8">
633
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
634
+ <title>{{> title}}</title>
635
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
636
+ <script src="https://cdn.tailwindcss.com"></script>
637
+ {{> headExtras}}
638
+ </head>
639
+ <body class="bg-gray-100 font-sans">
640
+ {{> content}}
641
+ {{> footerScripts}}
642
+ </body>
643
+ </html>
644
+ `
645
+
646
+ handlebars authLayout = `
647
+ {{#*inline "authHeader"}}
648
+ <div class="flex justify-between items-center border-b-2 border-blue-500 pb-3 mb-6">
649
+ <h1 class="text-3xl font-bold text-gray-800">{{> pageTitle}}</h1>
650
+ <div class="flex items-center space-x-4">
651
+ <span class="text-gray-600">Welcome, {{user.login}}!</span>
652
+ <button hx-post="/logout" hx-swap="none" hx-on::after-request="window.location.href='/login-page'" class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">Logout</button>
653
+ </div>
654
+ </div>
655
+ {{/inline}}
656
+ {{#*inline "content"}}
657
+ <div class="max-w-4xl mx-auto p-6">
658
+ <div class="bg-white p-6 rounded-lg shadow-md">
659
+ {{> authHeader}}
660
+ {{> pageContent}}
661
+ </div>
662
+ </div>
663
+ {{/inline}}
664
+ {{> baseLayout}}
665
+ `
666
+ POST /login
667
+ |> validate: `{
668
+ login: string(3..50),
669
+ password: string(6..100)
670
+ }`
671
+ |> auth: "login"
672
+ |> result
673
+ ok(200):
674
+ |> jq: `{
675
+ success: true,
676
+ message: "Login successful",
677
+ user: {
678
+ id: .user.id | tostring,
679
+ login: .user.login,
680
+ email: .user.email,
681
+ type: .user.type
682
+ }
683
+ }`
684
+ validationError(400):
685
+ |> jq: `{
686
+ success: false,
687
+ error: "Validation failed",
688
+ field: .errors[0].context,
689
+ message: .errors[0].message
690
+ }`
691
+ |> handlebars: `
692
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
693
+ <strong class="font-bold">Validation Error:</strong>
694
+ <span class="block sm:inline">{{message}}</span>
695
+ </div>
696
+ `
697
+ authError(401):
698
+ |> jq: `{
699
+ success: false,
700
+ error: "Login failed",
701
+ message: .errors[0].message,
702
+ context: .errors[0].context,
703
+ fullError: .errors[0]
704
+ }`
705
+ |> handlebars: `
706
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
707
+ <strong class="font-bold">Login Failed:</strong>
708
+ <span class="block sm:inline">{{message}}</span>
709
+ {{#context}}<br><small>Context: {{context}}</small>{{/context}}
710
+ </div>
711
+ `
712
+
713
+ POST /logout
714
+ |> auth: "logout"
715
+ |> result
716
+ ok(200):
717
+ |> jq: `{
718
+ success: true,
719
+ message: "Logged out successfully"
720
+ }`
721
+ authError(401):
722
+ |> jq: `{
723
+ success: false,
724
+ error: "Logout failed",
725
+ message: .errors[0].message
726
+ }`
727
+
728
+ POST /register
729
+ |> validate: `{
730
+ login: string(3..50),
731
+ email: email,
732
+ password: string(8..100)
733
+ }`
734
+ |> auth: "register"
735
+ |> result
736
+ ok(201):
737
+ |> jq: `{
738
+ success: true,
739
+ message: "Registration successful"
740
+ }`
741
+ |> handlebars: `
742
+ <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
743
+ <strong class="font-bold">Success!</strong>
744
+ <span class="block sm:inline">Account created successfully. Redirecting to login...</span>
745
+ </div>
746
+ `
747
+ validationError(400):
748
+ |> jq: `{
749
+ success: false,
750
+ error: "Validation failed",
751
+ field: .errors[0].context,
752
+ message: .errors[0].message
753
+ }`
754
+ |> handlebars: `
755
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
756
+ <strong class="font-bold">Validation Error:</strong>
757
+ <span class="block sm:inline">{{message}}</span>
758
+ </div>
759
+ `
760
+ authError(409):
761
+ |> jq: `{
762
+ success: false,
763
+ error: "Registration failed",
764
+ message: .errors[0].message
765
+ }`
766
+ |> handlebars: `
767
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
768
+ <strong class="font-bold">Registration Failed:</strong>
769
+ <span class="block sm:inline">{{message}}</span>
770
+ </div>
771
+ `
772
+
773
+ GET /login-page
774
+ |> jq: `{
775
+ pageTitle: "Login - Todo App",
776
+ message: "Please log in to access your todos"
777
+ }`
778
+ |> handlebars: `
779
+ {{#*inline "content"}}
780
+ <body class="bg-gray-100 font-sans">
781
+ <div class="max-w-md mx-auto mt-20 p-6">
782
+ <div class="bg-white p-8 rounded-lg shadow-md">
783
+ <div class="text-center mb-8">
784
+ <h1 class="text-3xl font-bold text-gray-800 mb-2">Welcome Back</h1>
785
+ <p class="text-gray-600">{{message}}</p>
786
+ </div>
787
+
788
+ <div id="login-response" class="mb-4"></div>
789
+
790
+ <form hx-post="/login" hx-target="#login-response" hx-swap="innerHTML" class="space-y-4">
791
+ <div>
792
+ <label for="login" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
793
+ <input type="text" id="login" name="login" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter your username">
794
+ </div>
795
+ <div>
796
+ <label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
797
+ <input type="password" id="password" name="password" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter your password">
798
+ </div>
799
+ <button type="submit" class="w-full bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
800
+ Sign In
801
+ </button>
802
+ </form>
803
+
804
+ <div class="mt-6 text-center">
805
+ <p class="text-sm text-gray-600">
806
+ Don't have an account?
807
+ <a href="/register-page" class="text-blue-500 hover:text-blue-600 font-medium">Sign up</a>
808
+ </p>
809
+ </div>
810
+
811
+ <div class="mt-4 text-center">
812
+ <a href="/hello" class="text-sm text-gray-500 hover:text-gray-700">← Back to Home</a>
813
+ </div>
814
+ </div>
815
+ </div>
816
+
817
+ <script>
818
+ // Redirect to todos on successful login
819
+ document.body.addEventListener('htmx:afterRequest', function(event) {
820
+ if (event.detail.xhr.status === 200 && event.detail.target.id === 'login-response') {
821
+ try {
822
+ const response = JSON.parse(event.detail.xhr.responseText);
823
+ if (response.success) {
824
+ window.location.href = '/todos';
825
+ }
826
+ } catch (e) {
827
+ // If not JSON, might be an error message
828
+ }
829
+ }
830
+ });
831
+ </script>
832
+ <!-- content -->
833
+ {{/inline}}
834
+ {{> baseLayout}}
835
+ `
836
+
837
+ GET /register-page
838
+ |> jq: `{
839
+ pageTitle: "Register - Todo App",
840
+ message: "Create your account to get started"
841
+ }`
842
+ |> handlebars: `
843
+ {{#*inline "content"}}
844
+ <div class="max-w-md mx-auto mt-20 p-6">
845
+ <div class="bg-white p-8 rounded-lg shadow-md">
846
+ <div class="text-center mb-8">
847
+ <h1 class="text-3xl font-bold text-gray-800 mb-2">Create Account</h1>
848
+ <p class="text-gray-600">{{message}}</p>
849
+ </div>
850
+
851
+ <div id="register-response" class="mb-4"></div>
852
+
853
+ <form hx-post="/register" hx-target="#register-response" hx-swap="innerHTML" class="space-y-4">
854
+ <div>
855
+ <label for="login" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
856
+ <input type="text" id="login" name="login" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Choose a username (3-50 chars)">
857
+ </div>
858
+ <div>
859
+ <label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
860
+ <input type="email" id="email" name="email" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter your email">
861
+ </div>
862
+ <div>
863
+ <label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
864
+ <input type="password" id="password" name="password" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Choose a password (8+ chars)">
865
+ </div>
866
+ <button type="submit" class="w-full bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
867
+ Create Account
868
+ </button>
869
+ </form>
870
+
871
+ <div class="mt-6 text-center">
872
+ <p class="text-sm text-gray-600">
873
+ Already have an account?
874
+ <a href="/login-page" class="text-blue-500 hover:text-blue-600 font-medium">Sign in</a>
875
+ </p>
876
+ </div>
877
+
878
+ <div class="mt-4 text-center">
879
+ <a href="/hello" class="text-sm text-gray-500 hover:text-gray-700">← Back to Home</a>
880
+ </div>
881
+ </div>
882
+ </div>
883
+
884
+ <script>
885
+ // Redirect to login on successful registration
886
+ document.body.addEventListener('htmx:afterRequest', function(event) {
887
+ if (event.detail.xhr.status === 200 && event.detail.target.id === 'register-response') {
888
+ try {
889
+ const response = JSON.parse(event.detail.xhr.responseText);
890
+ if (response.success) {
891
+ window.location.href = '/login-page';
892
+ }
893
+ } catch (e) {
894
+ // If not JSON, might be an error message
895
+ }
896
+ }
897
+ });
898
+ </script>
899
+ {{/inline}}
900
+ {{> baseLayout}}
901
+ `
902
+
903
+ handlebars loginRequiredLayout = `
904
+ {{#*inline "title"}}Login Required{{/inline}}
905
+ {{#*inline "content"}}
906
+ <div class="max-w-md mx-auto mt-20 p-6">
907
+ <div class="bg-white p-8 rounded-lg shadow-md text-center">
908
+ <div class="mb-6">
909
+ <svg class="mx-auto h-16 w-16 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
910
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
911
+ </svg>
912
+ </div>
913
+ <h1 class="text-2xl font-bold text-gray-800 mb-4">Authentication Required</h1>
914
+ <p class="text-gray-600 mb-6">You need to log in to access your todo list.</p>
915
+ <div class="space-y-3">
916
+ <a href="/login-page" class="block w-full bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">Go to Login</a>
917
+ <a href="/hello" class="block w-full bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">Go to Home</a>
918
+ </div>
919
+ </div>
920
+ </div>
921
+ {{/inline}}
922
+ {{> baseLayout}}
923
+ `
924
+
925
+ handlebars errorAlert = `
926
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 mt-4" role="alert">
927
+ <strong class="font-bold">{{> errorTitle}}:</strong>
928
+ <span class="block sm:inline">{{> errorMessage}}</span>
929
+ </div>
930
+ `
931
+
932
+ GET /todos
933
+ |> auth: "required"
934
+ |> result
935
+ ok(200):
936
+ |> jq: `. + { sqlParams: [.user.id] }`
937
+ |> pg: `SELECT id, title, completed, created_at, updated_at FROM todos WHERE user_id = $1 ORDER BY created_at DESC`
938
+ |> jq: `. + {
939
+ todos: .data.rows | map(. + {id: (.id | tostring)}),
940
+ pageTitle: "Todo List"
941
+ }`
942
+ |> handlebars: `
943
+ {{#*inline "title"}}{{pageTitle}}{{/inline}}
944
+ {{#*inline "pageTitle"}}{{pageTitle}} - {{user.login}}{{/inline}}
945
+ {{#*inline "pageContent"}}
946
+ <div class="bg-gray-50 p-6 rounded-lg mb-6">
947
+ <h3 class="text-lg font-semibold mb-4">Add New Todo</h3>
948
+ <div id="form-errors"></div>
949
+ <form hx-post="/todos/add" hx-target="#form-response" hx-swap="innerHTML" hx-on::response-error="document.getElementById('form-response').innerHTML = event.detail.xhr.responseText" class="space-y-4">
950
+ <div>
951
+ <label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title * (3-30 characters)</label>
952
+ <input type="text" id="title" name="title" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
953
+ </div>
954
+ <button type="submit" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">Add Todo</button>
955
+ </form>
956
+ <div id="form-response"></div>
957
+ </div>
958
+
959
+ <ul id="todo-list" class="space-y-3">
960
+ {{#each todos}}
961
+ {{> todoItemPartial}}
962
+ {{else}}
963
+ <li class="text-center text-gray-500 italic py-10">
964
+ <p>No todos yet. Add your first todo above!</p>
965
+ </li>
966
+ {{/each}}
967
+ </ul>
968
+ {{/inline}}
969
+ {{> authLayout}}
970
+ `
971
+ authError(401):
972
+ |> handlebars: `{{>loginRequiredLayout}}`
973
+
974
+ POST /todos/add
975
+ |> auth: "required"
976
+ |> result
977
+ ok(200):
978
+ |> validate: `
979
+ title: string(3..30)
980
+ `
981
+ |> jq: `. + {
982
+ sqlParams: [.body.title, false, .user.id]
983
+ }`
984
+ |> pg: `INSERT INTO todos (title, completed, user_id) VALUES ($1, $2, $3) RETURNING *`
985
+ |> result
986
+ ok(201):
987
+ |> jq: `(.data.rows[0] | . + {id: (.id | tostring)})`
988
+ |> handlebars: `
989
+ <div hx-swap-oob="afterbegin:#todo-list">
990
+ {{>todoItemPartial}}
991
+ </div>
992
+ <input type="text" id="title" name="title" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" value="" hx-swap-oob="true">
993
+ `
994
+ validationError(400):
995
+ |> jq: `{
996
+ error: "Validation failed",
997
+ field: .errors[0].field,
998
+ rule: .errors[0].rule,
999
+ message: .errors[0].message
1000
+ }`
1001
+ |> handlebars: `
1002
+ {{#*inline "errorTitle"}}Validation Error{{/inline}}
1003
+ {{#*inline "errorMessage"}}{{message}}{{/inline}}
1004
+ {{> errorAlert}}
1005
+ `
1006
+ authError(401):
1007
+ |> handlebars: `
1008
+ {{<errorAlert}}
1009
+ {{$errorTitle}}Authentication Required{{/errorTitle}}
1010
+ {{$errorMessage}}Please log in to add todos.{{/errorMessage}}
1011
+ {{/errorAlert}}
1012
+ `
1013
+
1014
+ POST /todos/:id/toggle
1015
+ |> auth: "required"
1016
+ |> result
1017
+ ok(200):
1018
+ |> jq: `{ sqlParams: [.params.id, .user.id], todoId: .params.id }`
1019
+ |> pg: `SELECT * FROM todos WHERE id = $1 AND user_id = $2`
1020
+ |> jq: `{
1021
+ sqlParams: [(.data.rows[0].completed | not), .todoId],
1022
+ currentTodo: .data.rows[0]
1023
+ }`
1024
+ |> pg: `UPDATE todos SET completed = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`
1025
+ |> jq: `.data.rows[0]`
1026
+ |> handlebars: `{{> todoItemPartial}}`
1027
+ authError(401):
1028
+ |> handlebars: `
1029
+ {{#*inline "errorTitle"}}Authentication Required{{/inline}}
1030
+ {{#*inline "errorMessage"}}Please log in to modify todos.{{/inline}}
1031
+ {{> errorAlert}}
1032
+ `
1033
+
1034
+ DELETE /todos/:id
1035
+ |> auth: "required"
1036
+ |> result
1037
+ ok(200):
1038
+ |> jq: `{ sqlParams: [.params.id, .user.id] }`
1039
+ |> pg: `DELETE FROM todos WHERE id = $1 AND user_id = $2`
1040
+ |> handlebars: ``
1041
+ authError(401):
1042
+ |> handlebars: `
1043
+ {{#*inline "errorTitle"}}Authentication Required{{/inline}}
1044
+ {{#*inline "errorMessage"}}Please log in to delete todos.{{/inline}}
1045
+ {{> errorAlert}}
1046
+ `
1047
+
1048
+ handlebars todoItemPartial = `
1049
+ <li class="{{#if completed}}bg-green-50 border-l-4 border-green-400{{else}}bg-gray-50 border-l-4 border-blue-400{{/if}} p-4 rounded-lg flex justify-between items-start">
1050
+ <div class="flex-1">
1051
+ <div class="{{#if completed}}text-gray-500 line-through{{else}}text-gray-800 font-medium{{/if}}">{{title}}</div>
1052
+ <div class="text-sm text-gray-500 mt-1">Created: {{created_at}}</div>
1053
+ </div>
1054
+ <div class="flex space-x-2 ml-4">
1055
+ {{#if completed}}
1056
+ <button hx-post="/todos/{{id}}/toggle" hx-target="closest li" hx-swap="outerHTML" class="px-3 py-1 text-sm bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">Mark Incomplete</button>
1057
+ {{else}}
1058
+ <button hx-post="/todos/{{id}}/toggle" hx-target="closest li" hx-swap="outerHTML" class="px-3 py-1 text-sm bg-green-500 hover:bg-green-600 text-white rounded transition-colors">Mark Complete</button>
1059
+ {{/if}}
1060
+ <button hx-delete="/todos/{{id}}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Are you sure you want to delete this todo?" class="px-3 py-1 text-sm bg-red-500 hover:bg-red-600 text-white rounded transition-colors">Delete</button>
1061
+ </div>
1062
+ </li>
1063
+ `
1064
+
1065
+ ## Test routes for new resultName functionality
1066
+
1067
+ # Test explicit resultName functionality
1068
+ GET /test/named-query/:id
1069
+ |> jq: `{ resultName: "userProfile" }`
1070
+ |> pg: `SELECT 1 as id, 'Test User' as name, 'test@example.com' as email`
1071
+ |> jq: `{ user: .data.userProfile }`
1072
+
1073
+ # Test multiple named results
1074
+ GET /test/multiple/:id
1075
+ |> jq: `{ resultName: "userInfo" }`
1076
+ |> pg: `SELECT 1 as id, 'Test User' as name`
1077
+ |> jq: `{ resultName: "userTeams" }`
1078
+ |> pg: `SELECT 1 as id, 'Engineering' as name UNION SELECT 2 as id, 'Product' as name`
1079
+ |> jq: `{
1080
+ profile: .data.userInfo,
1081
+ teams: .data.userTeams
1082
+ }`
1083
+
1084
+ # Test auto-naming with variables (should now use variable name as result key)
1085
+ pg getUserQuery = `SELECT 1 as id, 'Auto Named User' as name`
1086
+ pg getTeamsQuery = `SELECT 1 as id, 'Auto Team' as name`
1087
+
1088
+ GET /test/auto-naming/:id
1089
+ |> pg: getUserQuery
1090
+ |> pg: getTeamsQuery
1091
+ |> jq: `{
1092
+ user: .data.getUserQuery,
1093
+ teams: .data.getTeamsQuery,
1094
+ hasUserData: (.data.getUserQuery != null),
1095
+ hasTeamsData: (.data.getTeamsQuery != null)
1096
+ }`
1097
+
1098
+ # Test that legacy behavior is preserved (no resultName, no variable)
1099
+ GET /test/legacy/:id
1100
+ |> pg: `SELECT 1 as id, 'Legacy User' as name`
1101
+
1102
+ # Test Fetch Middleware
1103
+
1104
+ ## Basic GET request
1105
+ GET /test-fetch
1106
+ |> fetch: `https://api.github.com/zen`
1107
+
1108
+ ## GET with fetchUrl override
1109
+ GET /test-fetch-override
1110
+ |> jq: `{ fetchUrl: "https://api.github.com/zen" }`
1111
+ |> fetch: `https://example.com`
1112
+
1113
+ ## POST with body
1114
+ GET /test-fetch-post
1115
+ |> jq: `{
1116
+ fetchMethod: "POST",
1117
+ fetchBody: { name: "test", value: 123 },
1118
+ fetchHeaders: { "Content-Type": "application/json" }
1119
+ }`
1120
+ |> fetch: `https://api.github.com/zen`
1121
+
1122
+ ## Named result
1123
+ GET /test-fetch-named
1124
+ |> jq: `{ resultName: "apiCall" }`
1125
+ |> fetch: `https://api.github.com/zen`
1126
+ |> jq: `{
1127
+ response: .data.apiCall.response,
1128
+ status: .data.apiCall.status,
1129
+ success: (.data.apiCall.status == 200)
1130
+ }`
1131
+
1132
+ ## Test error handling
1133
+ GET /test-fetch-error
1134
+ |> fetch: `https://nonexistent-domain-12345.com`
1135
+
1136
+ ## Test timeout (if httpbin is slow)
1137
+ GET /test-fetch-timeout
1138
+ |> jq: `{ fetchTimeout: 1 }`
1139
+ |> fetch: `https://httpbin.org/delay/5`