Initial commit
All checks were successful
/ test (push) Successful in 9s

Safsaf is a Guile web framework, written using Claude Code running
Claude Opus 4.6, based off of the Guix Data Service, Nar Herder and
Guix Build Coordinator codebases.
This commit is contained in:
Christopher Baines 2026-04-13 14:24:19 +03:00
commit 5b0e6397dc
53 changed files with 7427 additions and 0 deletions

73
tests/CLAUDE.md Normal file
View file

@ -0,0 +1,73 @@
# Testing
## Framework
Tests use a minimal SRFI-269 implementation in `(tests support)`. Three
primitives — `is`, `test`, `suite` — build first-class test entities and
deliver them to a pluggable runner. Definition is separated from execution.
## Running tests
All tests (via Automake):
make check
Single file:
./pre-inst-env guile tests/test-router.scm
## Writing tests
```scheme
(use-modules (tests support)
(safsaf router)) ; module under test
(define-suite router-tests
(suite "route construction"
(test "creates route with method and pattern"
(let ((r (route 'GET '("users") identity)))
(is (route? r))
(is (eq? 'GET (route-method r))))))
(suite "matching"
(test "exact path match"
...)))
(run-tests router-tests)
```
Key points:
- `(is expr)` — assert expr is truthy. Returns the value on success.
- `(is (pred arg ...))` — predicate form; on failure shows evaluated args.
- `(test "desc" body ...)` — a single test case with one or more assertions.
- `(suite "desc" body ...)` — group tests and nested suites.
- `(define-suite name body ...)` — bind a suite-thunk to a variable.
- `(run-tests thunk)` — run with the simple runner, print summary, exit.
- Tests should be self-contained: don't depend on ordering or side effects
from other tests.
- Use `define` inside `test` bodies for local setup.
## Synthetic requests
Many tests need Guile `<request>` objects without a real HTTP server.
Build them with `build-request` from `(web request)`:
```scheme
(use-modules (web request) (web uri))
(define* (make-request method path #:optional (headers '()))
(build-request (build-uri 'http #:host "localhost" #:path path)
#:method method
#:headers headers))
```
Handler signature is `(request body-port) → (values response body)`. When
calling handlers or wrapped handlers in tests, pass `#f` as the body-port:
```scheme
(let ((resp body (wrapped (make-request 'GET "/" '()) #f)))
(is (= 200 (response-code resp))))
```
For handlers that read `current-route-params`, `parameterize` it directly.

243
tests/support.scm Normal file
View file

@ -0,0 +1,243 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
(define-module (tests support)
#:use-module (ice-9 format)
#:use-module (srfi srfi-1)
#:export (is
test
suite
suite-thunk
define-suite
test-runner*
test?
suite?
suite-thunk?
run-tests))
;;;
;;; Minimal SRFI-269 implementation for Guile.
;;;
;;; Three definition primitives — is, test, suite — construct first-class
;;; entities (alists) and deliver them to a pluggable test runner via
;;; message passing. Definition is separated from execution: the runner
;;; decides when and how to run things.
;;;
;;; --- Parameter ---
(define test-runner* (make-parameter #f))
;;; --- Predicates ---
(define (test? obj)
(and (pair? obj)
(assq 'test/body-thunk obj)
(assq 'test/description obj)
#t))
(define (suite? obj)
(and (pair? obj)
(assq 'suite/body-thunk obj)
(assq 'suite/description obj)
#t))
(define (suite-thunk? obj)
(procedure? obj))
;;; --- is ---
(define-syntax is
(lambda (x)
(syntax-case x ()
;; Predicate form: (is (pred arg ...))
((_ (pred arg ...))
(with-syntax ((src (datum->syntax x (syntax-source x))))
#'(%run-assert
(lambda () (pred arg ...))
'(pred arg ...)
'src
(lambda () (list arg ...)))))
;; Simple form: (is expr)
((_ expr)
(with-syntax ((src (datum->syntax x (syntax-source x))))
#'(%run-assert
(lambda () expr)
'expr
'src
#f))))))
(define (%run-assert body-thunk body-datum source args-thunk)
(let* ((entity `((assert/body-thunk . ,body-thunk)
(assert/body . ,body-datum)
(assert/location . ,source)
,@(if args-thunk
`((assert/args-thunk . ,args-thunk))
'()))))
((test-runner*)
`((type . runner/run-assert)
(assert . ,entity)))))
;;; --- test ---
(define-syntax test
(syntax-rules (quote)
((_ desc (quote metadata) meta body ...)
(%load-test desc 'meta (lambda () body ... (values))))
((_ desc body ...)
(%load-test desc '() (lambda () body ... (values))))))
(define (%load-test description metadata body-thunk)
((test-runner*)
`((type . runner/load-test)
(test . ((test/body-thunk . ,body-thunk)
(test/description . ,description)
(test/metadata . ,metadata))))))
;;; --- suite ---
(define-syntax suite
(syntax-rules (quote)
((_ desc (quote metadata) meta body ...)
(%load-suite desc 'meta (lambda () body ... (values))))
((_ desc body ...)
(%load-suite desc '() (lambda () body ... (values))))))
(define (%load-suite description metadata body-thunk)
((test-runner*)
`((type . runner/load-suite)
(suite . ((suite/body-thunk . ,body-thunk)
(suite/description . ,description)
(suite/metadata . ,metadata))))))
;;; --- suite-thunk ---
(define-syntax suite-thunk
(syntax-rules (quote)
((_ desc (quote metadata) meta body ...)
(lambda ()
(%load-suite desc 'meta (lambda () body ... (values)))))
((_ desc body ...)
(lambda ()
(%load-suite desc '() (lambda () body ... (values)))))))
;;; --- define-suite ---
(define-syntax define-suite
(syntax-rules (quote)
((_ name (quote metadata) meta body ...)
(define name
(suite-thunk (symbol->string 'name) (quote metadata) meta body ...)))
((_ name body ...)
(define name
(suite-thunk (symbol->string 'name) body ...)))))
;;;
;;; Simple immediate-execution test runner.
;;;
(define %depth 0)
(define %pass-count 0)
(define %fail-count 0)
(define %error-count 0)
(define %test-failed? #f)
(define (indent)
(make-string (* 2 %depth) #\space))
(define (format-location loc)
"Return a string like \"file.scm:42\" from a source location alist,
or #f if location info is unavailable."
(and loc
(let ((file (assq-ref loc 'filename))
(line (assq-ref loc 'line)))
(and file line
(format #f "~a:~a" file (+ line 1))))))
(define (simple-test-runner message)
(let ((type (assq-ref message 'type)))
(case type
((runner/load-suite)
(let* ((s (assq-ref message 'suite))
(desc (assq-ref s 'suite/description))
(body (assq-ref s 'suite/body-thunk)))
(format #t "~a~a~%" (indent) desc)
(set! %depth (+ %depth 1))
(body)
(set! %depth (- %depth 1))))
((runner/load-test)
(let* ((t (assq-ref message 'test))
(desc (assq-ref t 'test/description))
(body (assq-ref t 'test/body-thunk)))
(set! %test-failed? #f)
(with-exception-handler
(lambda (exn)
(set! %error-count (+ %error-count 1))
(format #t "~aERROR ~a~%" (indent) desc)
(format #t "~a ~a~%" (indent) exn))
(lambda ()
(body)
(if %test-failed?
(begin
(set! %fail-count (+ %fail-count 1))
(format #t "~aFAIL ~a~%" (indent) desc))
(begin
(set! %pass-count (+ %pass-count 1))
(format #t "~aok ~a~%" (indent) desc))))
#:unwind? #t)))
((runner/run-assert)
(let* ((a (assq-ref message 'assert))
(body-thunk (assq-ref a 'assert/body-thunk))
(body-datum (assq-ref a 'assert/body))
(loc (assq-ref a 'assert/location)))
(let ((result (body-thunk)))
(unless result
(set! %test-failed? #t)
(format #t "~a FAIL: ~s" (indent) body-datum)
(let ((loc-str (format-location loc)))
(when loc-str
(format #t " at ~a" loc-str)))
(newline)
;; Show evaluated arguments for predicate assertions.
(let ((args-thunk (assq-ref a 'assert/args-thunk)))
(when args-thunk
(with-exception-handler
(lambda (_) #f)
(lambda ()
(let ((args (args-thunk)))
(format #t "~a args: ~s~%" (indent) args)))
#:unwind? #t))))
result))))))
(define (run-tests thunk)
"Set up the simple test runner, call THUNK (typically a suite-thunk),
print a summary, and exit with 0 on success or 1 on failure."
(set! %depth 0)
(set! %pass-count 0)
(set! %fail-count 0)
(set! %error-count 0)
(parameterize ((test-runner* simple-test-runner))
(thunk))
(newline)
(let ((total (+ %pass-count %fail-count %error-count)))
(format #t "~a passed, ~a failed, ~a errors (of ~a)~%"
%pass-count %fail-count %error-count total)
(exit (if (and (zero? %fail-count) (zero? %error-count)) 0 1))))

View file

@ -0,0 +1,65 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; test-csrf-validation.scm — Tests for CSRF integration in (safsaf params)
;;; and (safsaf handler-wrappers csrf)
(use-modules (tests support)
(safsaf params)
(safsaf handler-wrappers csrf))
(define-suite csrf-validation-tests
(suite "csrf"
(test "csrf-token-field produces sxml"
(parameterize ((current-csrf-token "abc123"))
(let ((field (csrf-token-field)))
(is (pair? field))
(is (eq? 'input (car field))))))
(test "parse-form-params checks csrf"
(parameterize ((current-csrf-token "tok123"))
(let ((result (parse-form-params '()
'(("csrf-token" . "tok123")))))
(is (not (any-invalid-params? result))))
(let ((result (parse-form-params '()
'(("csrf-token" . "wrong")))))
(is (any-invalid-params? result)))))
(test "parse-form-params csrf missing"
(parameterize ((current-csrf-token "tok123"))
(let ((result (parse-form-params '() '())))
(is (any-invalid-params? result)))))
(test "parse-form-params validates other fields too"
(parameterize ((current-csrf-token "tok123"))
(let ((result (parse-form-params
`((name ,as-string #:required))
'(("csrf-token" . "tok123") ("name" . "Alice")))))
(is (not (any-invalid-params? result)))
(is (equal? "Alice" (assq-ref result 'name))))))
(test "parse-form-params field errors with valid csrf"
(parameterize ((current-csrf-token "tok123"))
(let ((result (parse-form-params
`((name ,as-string #:required))
'(("csrf-token" . "tok123")))))
(is (any-invalid-params? result))
(is (invalid-param? (assq-ref result 'name))))))))
(run-tests csrf-validation-tests)

146
tests/test-exceptions.scm Normal file
View file

@ -0,0 +1,146 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; test-exceptions.scm — Tests for (safsaf handler-wrappers exceptions)
(use-modules (tests support)
(safsaf handler-wrappers exceptions)
(srfi srfi-71)
(web request)
(web response)
(web uri))
(define (make-request method path headers)
(build-request (build-uri 'http #:host "localhost" #:path path)
#:method method
#:headers headers))
(define (failing-handler request body-port)
(error "test explosion"))
(define (ok-handler request body-port)
(values (build-response #:code 200) "ok"))
(define html-request
(make-request 'GET "/" '((accept . ((text/html))))))
(define json-request
(make-request 'GET "/" '((accept . ((application/json))))))
(define-suite exceptions-tests
(suite "passthrough"
(test "successful handler passes through unchanged"
(define wrapped (exceptions-handler-wrapper ok-handler))
(let ((resp body (wrapped html-request #f)))
(is (= 200 (response-code resp)))
(is (equal? "ok" body)))))
(suite "production mode"
(test "returns 500 for HTML client"
(define wrapped (exceptions-handler-wrapper failing-handler))
(let ((resp body (wrapped html-request #f)))
(is (= 500 (response-code resp)))
;; Body is a streaming procedure from html-response.
(is (procedure? body))))
(test "returns 500 JSON for JSON client"
(define wrapped (exceptions-handler-wrapper failing-handler))
(let ((resp body (wrapped json-request #f)))
(is (= 500 (response-code resp)))
(is (string? body))
;; Should contain error key but not backtrace.
(is (string-contains body "Internal Server Error"))
(is (not (string-contains body "test explosion"))))))
(suite "dev mode"
(test "returns 500 with backtrace for HTML client"
(define wrapped (exceptions-handler-wrapper failing-handler
#:dev? #t))
(let ((resp body (wrapped html-request #f)))
(is (= 500 (response-code resp)))
;; Body is a streaming procedure containing backtrace.
(is (procedure? body))))
(test "returns 500 JSON with backtrace for JSON client"
(define wrapped (exceptions-handler-wrapper failing-handler
#:dev? #t))
(let ((resp body (wrapped json-request #f)))
(is (= 500 (response-code resp)))
(is (string? body))
(is (string-contains body "backtrace"))
(is (string-contains body "test explosion")))))
(suite "custom renderers"
(test "render-error overrides everything"
(define wrapped
(exceptions-handler-wrapper
failing-handler
#:render-error
(lambda (request code message bt dev?)
(values (build-response #:code code) "custom error"))))
(let ((resp body (wrapped html-request #f)))
(is (= 500 (response-code resp)))
(is (equal? "custom error" body))))
(test "render-html overrides only HTML"
(define wrapped
(exceptions-handler-wrapper
failing-handler
#:render-html
(lambda (request code message bt dev?)
(values (build-response #:code code
#:headers '((content-type text/html)))
"custom html"))))
;; HTML request gets custom renderer.
(let ((resp body (wrapped html-request #f)))
(is (equal? "custom html" body)))
;; JSON request gets default JSON renderer.
(let ((resp body (wrapped json-request #f)))
(is (string? body))
(is (string-contains body "Internal Server Error"))))
(test "render-json overrides only JSON"
(define wrapped
(exceptions-handler-wrapper
failing-handler
#:render-json
(lambda (request code message bt dev?)
(values (build-response #:code code
#:headers '((content-type application/json)))
"{\"err\":\"custom\"}"))))
;; JSON request gets custom renderer.
(let ((resp body (wrapped json-request #f)))
(is (equal? "{\"err\":\"custom\"}" body)))
;; HTML request gets default HTML renderer.
(let ((resp body (wrapped html-request #f)))
(is (procedure? body)))))
(suite "make-exceptions-handler-wrapper"
(test "factory returns a working wrapper"
(define wrapper (make-exceptions-handler-wrapper #:dev? #t))
(define wrapped (wrapper failing-handler))
(let ((resp body (wrapped json-request #f)))
(is (= 500 (response-code resp)))
(is (string-contains body "test explosion"))))))
(run-tests exceptions-tests)

View file

@ -0,0 +1,274 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; test-handler-wrappers.scm — Tests for standalone handler wrappers
(use-modules (tests support)
(safsaf handler-wrappers security-headers)
(safsaf handler-wrappers cors)
(safsaf handler-wrappers max-body-size)
(safsaf handler-wrappers sessions)
(safsaf handler-wrappers trailing-slash)
(srfi srfi-71)
(web request)
(web response)
(web uri)
(webutils cookie)) ; registers Cookie header parser
;; A handler that returns a plain 200 response.
(define (ok-handler request body-port)
(values (build-response #:code 200) "ok"))
(define* (make-request method path headers #:key (validate? #t))
(build-request (build-uri 'http #:host "localhost" #:path path)
#:method method
#:headers headers
#:validate-headers? validate?))
(define-suite handler-wrappers-tests
(suite "security-headers"
(test "adds default headers"
(define wrapped (security-headers-handler-wrapper ok-handler))
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
(is (equal? "nosniff"
(assq-ref (response-headers resp)
'x-content-type-options)))
(is (equal? "DENY"
(assq-ref (response-headers resp)
'x-frame-options)))))
(test "disabling a header with #f"
(define wrapped
(security-headers-handler-wrapper ok-handler
#:frame-options #f))
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
(is (not (assq-ref (response-headers resp)
'x-frame-options)))))
(test "content-security-policy header"
(define wrapped
(security-headers-handler-wrapper ok-handler
#:content-security-policy "default-src 'self'; script-src 'self'"))
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
(is (equal? "default-src 'self'; script-src 'self'"
(assq-ref (response-headers resp)
'content-security-policy)))))
(test "content-security-policy-report-only header"
(define wrapped
(security-headers-handler-wrapper ok-handler
#:content-security-policy-report-only "default-src 'self'"))
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
(is (equal? "default-src 'self'"
(assq-ref (response-headers resp)
'content-security-policy-report-only)))
;; Enforcing header should not be set.
(is (not (assq-ref (response-headers resp)
'content-security-policy))))))
(suite "cors"
(test "no origin header passes through"
(define wrapped (cors-handler-wrapper ok-handler))
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
(is (= 200 (response-code resp)))
(is (not (assq-ref (response-headers resp)
'access-control-allow-origin)))))
(test "preflight returns 204"
(define wrapped (cors-handler-wrapper ok-handler))
(let ((resp _body
(wrapped (make-request 'OPTIONS "/"
'((origin . "http://example.com")))
#f)))
(is (= 204 (response-code resp)))
(is (equal? "*" (assq-ref (response-headers resp)
'access-control-allow-origin)))))
(test "normal request with origin adds cors headers"
(define wrapped (cors-handler-wrapper ok-handler))
(let ((resp _body
(wrapped (make-request 'GET "/"
'((origin . "http://example.com")))
#f)))
(is (= 200 (response-code resp)))
(is (assq-ref (response-headers resp)
'access-control-allow-origin))))
(test "disallowed origin gets no cors headers"
(define wrapped
(cors-handler-wrapper ok-handler
#:origins '("http://allowed.com")))
(let ((resp _body
(wrapped (make-request 'GET "/"
'((origin . "http://evil.com")))
#f)))
(is (= 200 (response-code resp)))
(is (not (assq-ref (response-headers resp)
'access-control-allow-origin))))))
(suite "sessions"
(test "round-trip set and read"
(define mgr (make-session-config "test-secret-key-1234"))
(define wrapper (make-session-handler-wrapper mgr))
;; Set a session and extract the cookie name=value.
(let* ((cookie-hdr (session-set mgr '((user . "alice"))))
;; cdr is (name value attrs...) — build parsed cookie alist
(cookie-name (car (cdr cookie-hdr)))
(cookie-value (cadr (cdr cookie-hdr)))
(cookie-alist (list (cons cookie-name cookie-value))))
;; Now make a request with that cookie and read it back.
(define reading-handler
(lambda (request body-port) (values (build-response #:code 200)
(current-session))))
(define wrapped (wrapper reading-handler))
(let ((_resp body
(wrapped (make-request 'GET "/"
`((cookie . ,cookie-alist))
#:validate? #f)
#f)))
(is (pair? body))
(is (equal? "alice" (assq-ref body 'user))))))
(test "missing session yields #f"
(define mgr (make-session-config "test-secret-key-1234"))
(define reading-handler
(lambda (request body-port) (values (build-response #:code 200)
(current-session))))
(define wrapped ((make-session-handler-wrapper mgr) reading-handler))
(let ((_resp body (wrapped (make-request 'GET "/" '()) #f)))
(is (not body)))))
(suite "max-body-size"
(test "allows request under limit"
(define wrapper (make-max-body-size-handler-wrapper 1024))
(define wrapped (wrapper ok-handler))
(let ((resp _body
(wrapped (make-request 'GET "/"
'((content-length . 512)))
#f)))
(is (= 200 (response-code resp)))))
(test "rejects request over limit with 413"
(define wrapper (make-max-body-size-handler-wrapper 1024))
(define wrapped (wrapper ok-handler))
(let ((resp _body
(wrapped (make-request 'GET "/"
'((content-length . 2048)))
#f)))
(is (= 413 (response-code resp)))))
(test "passes through when no content-length"
(define wrapper (make-max-body-size-handler-wrapper 1024))
(define wrapped (wrapper ok-handler))
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
(is (= 200 (response-code resp)))))
(test "custom 413 handler"
(define wrapper
(make-max-body-size-handler-wrapper
100
#:handler-413
(lambda (request body-port)
(values (build-response #:code 413) "too big"))))
(define wrapped (wrapper ok-handler))
(let ((resp body
(wrapped (make-request 'GET "/"
'((content-length . 200)))
#f)))
(is (= 413 (response-code resp)))
(is (equal? "too big" body)))))
(suite "trailing-slash"
(test "strip mode redirects trailing slash"
(define wrapped
(trailing-slash-handler-wrapper ok-handler #:mode 'strip))
(let ((resp _body (wrapped (make-request 'GET "/foo/" '()) #f)))
(is (= 301 (response-code resp)))
(is (equal? "/foo"
(uri->string
(assq-ref (response-headers resp) 'location))))))
(test "strip mode passes through without trailing slash"
(define wrapped
(trailing-slash-handler-wrapper ok-handler #:mode 'strip))
(let ((resp _body (wrapped (make-request 'GET "/foo" '()) #f)))
(is (= 200 (response-code resp)))))
(test "append mode redirects missing trailing slash"
(define wrapped
(trailing-slash-handler-wrapper ok-handler #:mode 'append))
(let ((resp _body (wrapped (make-request 'GET "/foo" '()) #f)))
(is (= 301 (response-code resp)))
(is (equal? "/foo/"
(uri->string
(assq-ref (response-headers resp) 'location))))))
(test "append mode passes through with trailing slash"
(define wrapped
(trailing-slash-handler-wrapper ok-handler #:mode 'append))
(let ((resp _body (wrapped (make-request 'GET "/foo/" '()) #f)))
(is (= 200 (response-code resp)))))
(test "root path passes through in strip mode"
(define wrapped
(trailing-slash-handler-wrapper ok-handler #:mode 'strip))
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
(is (= 200 (response-code resp)))))
(test "root path passes through in append mode"
(define wrapped
(trailing-slash-handler-wrapper ok-handler #:mode 'append))
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
(is (= 200 (response-code resp)))))
(test "preserves query string"
(define wrapped
(trailing-slash-handler-wrapper ok-handler #:mode 'strip))
(let* ((req (build-request
(build-uri 'http #:host "localhost"
#:path "/foo/" #:query "bar=1")
#:method 'GET #:headers '()))
(resp _body (wrapped req #f)))
(is (= 301 (response-code resp)))
(is (equal? "/foo?bar=1"
(uri->string
(assq-ref (response-headers resp) 'location))))))
(test "custom status code"
(define wrapped
(trailing-slash-handler-wrapper ok-handler
#:mode 'strip #:code 302))
(let ((resp _body (wrapped (make-request 'GET "/foo/" '()) #f)))
(is (= 302 (response-code resp)))))
(test "make-trailing-slash-handler-wrapper factory"
(define wrapper
(make-trailing-slash-handler-wrapper #:mode 'append #:code 308))
(define wrapped (wrapper ok-handler))
(let ((resp _body (wrapped (make-request 'GET "/foo" '()) #f)))
(is (= 308 (response-code resp)))
(is (equal? "/foo/"
(uri->string
(assq-ref (response-headers resp) 'location))))))))
(run-tests handler-wrappers-tests)

273
tests/test-integration.scm Normal file
View file

@ -0,0 +1,273 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; test-integration.scm — Full-stack integration tests
;;;
;;; Starts a real HTTP server inside run-fibers and makes requests
;;; via a thread pool (Guile's (web client) uses blocking I/O that
;;; does not cooperate with the fibers scheduler).
(use-modules (tests support)
(fibers)
(knots web-server)
(knots thread-pool)
(safsaf)
(safsaf router)
(safsaf response-helpers)
(safsaf utils)
(srfi srfi-71)
(rnrs bytevectors)
(web client)
(web request)
(web response)
(web uri))
(define test-port 8399)
(define test-base (string-append "http://127.0.0.1:"
(number->string test-port)))
;;;
;;; Test handlers
;;;
(define (echo-form-handler request body-port)
"Parse a URL-encoded form body and echo each field as key=value lines."
(let ((fields (parse-form-body request body-port)))
(text-response
(string-join (map (lambda (pair)
(string-append (car pair) "=" (cdr pair)))
fields)
"\n"))))
(define (echo-multipart-handler request body-port)
"Parse a multipart body, extract text fields, echo as key=value lines."
(let* ((parts (parse-multipart-body request body-port))
(fields (multipart-text-fields parts)))
(text-response
(string-join (map (lambda (pair)
(string-append (car pair) "=" (cdr pair)))
fields)
"\n"))))
(define (greet-handler request body-port)
(text-response "hello"))
(define (catch-all-handler request body-port)
(not-found-response))
;;;
;;; Route table
;;;
(define test-routes
(list
(route 'GET '("greet") greet-handler)
(route 'POST '("form") echo-form-handler)
(route 'POST '("multipart") echo-multipart-handler)
(route '* '(. rest) catch-all-handler)))
;;;
;;; Multipart body construction
;;;
(define (make-multipart-body boundary fields)
"Build a multipart/form-data body bytevector from FIELDS,
an alist of (name . value) string pairs."
(let ((parts
(string-join
(map (lambda (pair)
(string-append
"--" boundary "\r\n"
"Content-Disposition: form-data; name=\""
(car pair) "\"\r\n"
"\r\n"
(cdr pair)))
fields)
"\r\n")))
(string->utf8
(string-append parts "\r\n"
"--" boundary "--\r\n"))))
;;;
;;; Test runner
;;;
(define %pass 0)
(define %fail 0)
(define (check desc ok?)
(if ok?
(begin
(set! %pass (1+ %pass))
(format #t " ok ~a~%" desc))
(begin
(set! %fail (1+ %fail))
(format #t " FAIL ~a~%" desc)))
(force-output))
;;; HTTP client helper — runs requests on a thread pool because
;;; Guile's (web client) uses blocking I/O incompatible with fibers.
(define http-pool (make-fixed-size-thread-pool 1))
(define (test-post path headers body)
"POST to the test server. Returns (values response body-string)."
(call-with-thread
http-pool
(lambda ()
(http-post (string-append test-base path)
#:headers headers
#:body body))))
(define (test-get path)
"GET from the test server. Returns (values response body-string)."
(call-with-thread
http-pool
(lambda ()
(http-get (string-append test-base path)))))
(define (test-head path)
"HEAD to the test server. Returns (values response body-string)."
(call-with-thread
http-pool
(lambda ()
(http-head (string-append test-base path)))))
(define (test-delete path)
"DELETE to the test server. Returns (values response body-string)."
(call-with-thread
http-pool
(lambda ()
(http-delete (string-append test-base path)))))
;;;
;;; Run everything inside a single run-fibers.
;;;
(run-fibers
(lambda ()
(run-safsaf test-routes
#:host "127.0.0.1"
#:port test-port)
(sleep 1)
(with-exception-handler
(lambda (exn)
(format (current-error-port) "~%Test error: ~a~%" exn)
(force-output (current-error-port))
(primitive-_exit 1))
(lambda ()
(format #t "parse-form-body via HTTP~%")
;; Test 1: basic url-encoded fields
(let ((resp body
(test-post "/form"
'((content-type
. (application/x-www-form-urlencoded)))
"name=Alice&age=30")))
(check "basic url-encoded fields"
(and (= 200 (response-code resp))
(string-contains body "name=Alice")
(string-contains body "age=30"))))
;; Test 2: plus signs decoded as spaces
(let ((resp body
(test-post "/form"
'((content-type
. (application/x-www-form-urlencoded)))
"greeting=hello+world")))
(check "plus signs decoded as spaces"
(and (= 200 (response-code resp))
(string-contains body "greeting=hello world"))))
;; Test 3: percent-encoded values
(let ((resp body
(test-post "/form"
'((content-type
. (application/x-www-form-urlencoded)))
"msg=caf%C3%A9")))
(check "percent-encoded values"
(and (= 200 (response-code resp))
(string-contains body "msg=café"))))
(format #t "~%parse-multipart-body via HTTP~%")
;; Test 4: basic multipart text fields
(let* ((boundary "----TestBoundary12345")
(body-bv (make-multipart-body boundary
'(("title" . "Hello")
("body" . "World")))))
(let ((resp body
(test-post "/multipart"
`((content-type
. (multipart/form-data
(boundary . ,boundary))))
body-bv)))
(check "basic multipart text fields"
(and (= 200 (response-code resp))
(string-contains body "title=Hello")
(string-contains body "body=World")))))
(format #t "~%405 Method Not Allowed~%")
;; Test 5: POST to a GET-only route returns 405
(let ((resp body (test-post "/greet" '() "")))
(check "POST to GET-only route returns 405"
(= 405 (response-code resp)))
(check "405 response includes Allow header with GET and HEAD"
(let ((allowed (assq-ref (response-headers resp)
'allow)))
(and (memq 'GET allowed)
(memq 'HEAD allowed)))))
;; Test 6: DELETE to a GET-only route returns 405
(let ((resp _body (test-delete "/greet")))
(check "DELETE to GET-only route returns 405"
(= 405 (response-code resp))))
(format #t "~%Automatic HEAD handling~%")
;; Test 7: HEAD to a GET route returns 200 with empty body
(let ((resp body (test-head "/greet")))
(check "HEAD to GET route returns 200"
(= 200 (response-code resp)))
(check "HEAD response has empty body"
(or (not body)
(and (string? body) (string-null? body)))))
;; Test 8: HEAD to a non-existent path falls through to catch-all
(let ((resp _body (test-head "/no-such-path")))
(check "HEAD to unknown path returns 404"
(= 404 (response-code resp))))
;; Test 9: GET to the greet route works normally
(let ((resp body (test-get "/greet")))
(check "GET to greet route returns 200"
(= 200 (response-code resp)))
(check "GET to greet route returns body"
(string-contains body "hello")))
;; Summary and exit.
(newline)
(let ((total (+ %pass %fail)))
(format #t "~a passed, ~a failed (of ~a)~%"
%pass %fail total)
(force-output)
(primitive-_exit (if (zero? %fail) 0 1))))
#:unwind? #t))
#:drain? #f)

307
tests/test-params.scm Normal file
View file

@ -0,0 +1,307 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
(use-modules (tests support)
(safsaf params)
(safsaf handler-wrappers csrf))
(define-suite params-tests
(suite "processors"
(test "as-string passes through"
(is (equal? "hello" (as-string "hello"))))
(test "as-integer parses integers"
(is (equal? 42 (as-integer "42")))
(is (equal? -3 (as-integer "-3")))
(is (equal? 0 (as-integer "0"))))
(test "as-integer rejects non-integers"
(is (invalid-param? (as-integer "3.14")))
(is (invalid-param? (as-integer "abc")))
(is (invalid-param? (as-integer ""))))
(test "as-number parses numbers"
(is (equal? 42 (as-number "42")))
(is (equal? 3.14 (as-number "3.14")))
(is (equal? -1 (as-number "-1"))))
(test "as-number rejects non-numbers"
(is (invalid-param? (as-number "abc")))
(is (invalid-param? (as-number ""))))
(test "as-checkbox"
(is (eq? #t (as-checkbox "on")))
(is (eq? #f (as-checkbox "off")))
(is (eq? #f (as-checkbox ""))))
(test "as-one-of accepts valid choices"
(define proc (as-one-of '("red" "green" "blue")))
(is (equal? "red" (proc "red")))
(is (equal? "blue" (proc "blue"))))
(test "as-one-of rejects invalid choices"
(define proc (as-one-of '("red" "green" "blue")))
(is (invalid-param? (proc "yellow"))))
(test "as-one-of custom message"
(define proc (as-one-of '("a") #:message "nope"))
(is (equal? "nope" (invalid-param-message (proc "z")))))
(test "as-matching accepts matching values"
(define proc (as-matching "^[0-9]+$"))
(is (equal? "123" (proc "123"))))
(test "as-matching rejects non-matching values"
(define proc (as-matching "^[0-9]+$"))
(is (invalid-param? (proc "abc"))))
(test "as-predicate"
(define proc (as-predicate (lambda (s) (> (string-length s) 3))))
(is (equal? "hello" (proc "hello")))
(is (invalid-param? (proc "hi")))))
(suite "parse-params"
(test "basic optional param"
(define result
(parse-params `((name ,as-string))
'(("name" . "Alice"))))
(is (equal? "Alice" (assq-ref result 'name))))
(test "missing optional param omitted"
(define result
(parse-params `((name ,as-string))
'()))
(is (not (assq-ref result 'name))))
(test "empty string treated as absent for optional"
(define result
(parse-params `((name ,as-string))
'(("name" . ""))))
(is (not (assq-ref result 'name))))
(test "required param present"
(define result
(parse-params `((name ,as-string #:required))
'(("name" . "Bob"))))
(is (equal? "Bob" (assq-ref result 'name))))
(test "required param missing"
(define result
(parse-params `((name ,as-string #:required))
'()))
(is (invalid-param? (assq-ref result 'name))))
(test "required param empty"
(define result
(parse-params `((name ,as-string #:required))
'(("name" . ""))))
(is (invalid-param? (assq-ref result 'name))))
(test "default used when absent"
(define result
(parse-params `((limit ,as-integer #:default 50))
'()))
(is (equal? 50 (assq-ref result 'limit))))
(test "default not used when present"
(define result
(parse-params `((limit ,as-integer #:default 50))
'(("limit" . "10"))))
(is (equal? 10 (assq-ref result 'limit))))
(test "default used when empty string"
(define result
(parse-params `((limit ,as-integer #:default 50))
'(("limit" . ""))))
(is (equal? 50 (assq-ref result 'limit))))
(test "multi-value collects all"
(define result
(parse-params `((color ,as-string #:multi-value))
'(("color" . "red") ("color" . "blue"))))
(is (equal? '("red" "blue") (assq-ref result 'color))))
(test "multi-value empty omitted"
(define result
(parse-params `((color ,as-string #:multi-value))
'()))
(is (not (assq-ref result 'color))))
(test "multi-value skips empty strings"
(define result
(parse-params `((color ,as-string #:multi-value))
'(("color" . "red") ("color" . "") ("color" . "blue"))))
(is (equal? '("red" "blue") (assq-ref result 'color))))
(test "multi-value with default"
(define result
(parse-params `((color ,as-string #:multi-value #:default ("red")))
'()))
(is (equal? '("red") (assq-ref result 'color))))
(test "no-default-when suppresses default"
(define result
(parse-params `((limit ,as-integer
#:no-default-when (all_results) #:default 50)
(all_results ,as-checkbox))
'(("all_results" . "on"))))
(is (not (assq-ref result 'limit)))
(is (eq? #t (assq-ref result 'all_results))))
(test "no-default-when uses default when condition absent"
(define result
(parse-params `((limit ,as-integer
#:no-default-when (all_results) #:default 50))
'()))
(is (equal? 50 (assq-ref result 'limit))))
(test "processor transforms value"
(define result
(parse-params `((count ,as-integer))
'(("count" . "42"))))
(is (equal? 42 (assq-ref result 'count))))
(test "processor error appears inline"
(define result
(parse-params `((count ,as-integer))
'(("count" . "abc"))))
(is (invalid-param? (assq-ref result 'count))))
(test "multiple params parsed together"
(define result
(parse-params `((name ,as-string #:required)
(age ,as-integer #:default 0)
(active ,as-checkbox))
'(("name" . "Alice") ("active" . "on"))))
(is (equal? "Alice" (assq-ref result 'name)))
(is (equal? 0 (assq-ref result 'age)))
(is (eq? #t (assq-ref result 'active)))))
(suite "any-invalid-params?"
(test "no errors"
(is (not (any-invalid-params?
'((name . "Alice") (age . 30))))))
(test "with error"
(is (any-invalid-params?
`((name . ,(make-invalid-param #f "required"))))))
(test "error in multi-value list"
(is (any-invalid-params?
`((colors . ("red" ,(make-invalid-param "x" "bad"))))))))
(suite "invalid-param-ref"
(test "returns record when invalid"
(define params `((name . ,(make-invalid-param "" "required"))))
(is (invalid-param? (invalid-param-ref params 'name))))
(test "returns #f when valid"
(is (not (invalid-param-ref '((name . "Alice")) 'name))))
(test "returns #f when absent"
(is (not (invalid-param-ref '() 'name)))))
(suite "field-errors"
(test "returns error messages"
(define params `((name . ,(make-invalid-param "" "required"))))
(is (equal? '("required") (field-errors params 'name))))
(test "returns empty for valid field"
(is (equal? '() (field-errors '((name . "Alice")) 'name))))
(test "returns empty for absent field"
(is (equal? '() (field-errors '() 'name)))))
(suite "guard-against-mutually-exclusive-params"
(test "no conflict"
(define result
(guard-against-mutually-exclusive-params
'((limit . 50))
'((limit all_results))))
(is (equal? 50 (assq-ref result 'limit))))
(test "conflict marks both invalid"
(define result
(guard-against-mutually-exclusive-params
'((limit . 50) (all_results . #t))
'((limit all_results))))
(is (invalid-param? (assq-ref result 'limit)))
(is (invalid-param? (assq-ref result 'all_results))))
(test "preserves existing errors"
(define result
(guard-against-mutually-exclusive-params
`((name . ,(make-invalid-param #f "bad")))
'((name other))))
(is (invalid-param? (assq-ref result 'name)))))
(suite "params->query-string"
(test "simple params"
(is (equal? "name=Alice&age=30"
(params->query-string '((name . "Alice") (age . 30))))))
(test "boolean params"
(is (equal? "active=on"
(params->query-string '((active . #t))))))
(test "multi-value params"
(is (equal? "color=red&color=blue"
(params->query-string '((color . ("red" "blue")))))))
(test "skips invalid params"
(is (equal? "name=Alice"
(params->query-string
`((name . "Alice")
(bad . ,(make-invalid-param "x" "err")))))))
(test "empty result"
(is (equal? "" (params->query-string '())))))
(suite "parse-form-params"
(test "csrf pass"
(parameterize ((current-csrf-token "tok123"))
(define result
(parse-form-params
`((name ,as-string #:required))
'(("csrf-token" . "tok123") ("name" . "Alice"))))
(is (not (any-invalid-params? result)))
(is (equal? "Alice" (assq-ref result 'name)))))
(test "csrf fail"
(parameterize ((current-csrf-token "tok123"))
(define result
(parse-form-params
`((name ,as-string))
'(("csrf-token" . "wrong"))))
(is (any-invalid-params? result))))
(test "csrf missing"
(parameterize ((current-csrf-token "tok123"))
(define result
(parse-form-params `() '()))
(is (any-invalid-params? result))))))
(run-tests params-tests)

View file

@ -0,0 +1,152 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; test-response-helpers.scm — Tests for (safsaf response-helpers)
(use-modules (tests support)
(safsaf response-helpers)
(safsaf router)
(srfi srfi-71)
(web response)
(web request)
(web uri))
(define (body->string body)
"Capture a body (string or writer procedure) as a string."
(if (procedure? body)
(call-with-output-string body)
body))
(define (make-request method path headers)
(build-request (build-uri 'http #:host "localhost" #:path path)
#:method method
#:headers headers))
(define-suite response-helpers-tests
(suite "response constructors"
(test "html-response"
(let ((resp body (html-response '(p "hello"))))
(is (= 200 (response-code resp)))
(is (equal? '(text/html (charset . "utf-8"))
(assq-ref (response-headers resp) 'content-type)))
(is (string-contains (body->string body) "hello"))))
(test "json-response"
(let ((resp body (json-response "{\"a\":1}")))
(is (= 200 (response-code resp)))
(is (equal? '(application/json)
(assq-ref (response-headers resp) 'content-type)))
(is (equal? "{\"a\":1}" body))))
(test "text-response"
(let ((resp body (text-response "hi")))
(is (= 200 (response-code resp)))
(is (equal? "hi" body))))
(test "redirect-response"
(let ((resp _body (redirect-response "/foo")))
(is (= 303 (response-code resp)))
(is (response-headers resp))))
(test "custom code"
(let ((resp _body (text-response "x" #:code 201)))
(is (= 201 (response-code resp))))))
(suite "error responses"
(test "status codes"
(let ((r1 _b1 (not-found-response))
(r2 _b2 (forbidden-response))
(r3 _b3 (bad-request-response))
(r4 _b4 (internal-server-error-response)))
(is (= 404 (response-code r1)))
(is (= 403 (response-code r2)))
(is (= 400 (response-code r3)))
(is (= 500 (response-code r4))))))
(suite "streaming json"
(test "scm-alist->streaming-json"
(let ((out (call-with-output-string
(lambda (port)
(scm-alist->streaming-json
'(("name" . "Alice") ("age" . 30))
port)))))
(is (string-contains out "\"name\":\"Alice\""))
(is (string-contains out "\"age\":30"))))
(test "list->streaming-json-array"
(let ((out (call-with-output-string
(lambda (port)
(list->streaming-json-array
identity '(1 2 3) port)))))
(is (equal? "[1,2,3]" out)))))
(suite "content negotiation"
(test "path extension takes priority over accept header"
(let ((req (make-request 'GET "/things.json"
'((accept . ((text/html)))))))
(is (eq? 'application/json
(negotiate-content-type req '(text/html application/json))))))
(test "falls back to accept header without extension"
(let ((req (make-request 'GET "/things"
'((accept . ((application/json)))))))
(is (eq? 'application/json
(negotiate-content-type req '(text/html application/json))))))
(test "ignores extension not in supported list"
(let ((req (make-request 'GET "/things.txt"
'((accept . ((text/html)))))))
(is (eq? 'text/html
(negotiate-content-type req '(text/html application/json))))))
(test "defaults to first supported when nothing matches"
(let ((req (make-request 'GET "/things"
'((accept . ((image/png)))))))
(is (eq? 'text/html
(negotiate-content-type req '(text/html application/json)))))))
(suite "static handler"
(test "serves file and rejects traversal"
(let* ((tmp (tmpnam))
(_ (mkdir tmp))
(f (string-append tmp "/test.txt"))
(_ (call-with-output-file f
(lambda (p) (display "content" p))))
(handler (make-static-handler tmp)))
;; Serve existing file.
(parameterize ((current-route-params `((path . ("test.txt")))))
(let ((resp body (handler (make-request 'GET "/test.txt" '()) #f)))
(is (= 200 (response-code resp)))
(is (equal? "content" (body->string body)))))
;; Traversal rejected.
(parameterize ((current-route-params `((path . (".." "etc" "passwd")))))
(let ((resp _body (handler (make-request 'GET "/../etc/passwd" '()) #f)))
(is (= 404 (response-code resp)))))
;; Missing file.
(parameterize ((current-route-params `((path . ("nope.txt")))))
(let ((resp _body (handler (make-request 'GET "/nope.txt" '()) #f)))
(is (= 404 (response-code resp)))))
(delete-file f)
(rmdir tmp)))))
(run-tests response-helpers-tests)

188
tests/test-router.scm Normal file
View file

@ -0,0 +1,188 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; test-router.scm — Tests for (safsaf router)
(use-modules (tests support)
(safsaf router)
(srfi srfi-71))
;; Every compile-routes call needs a catch-all as the last route.
(define catch-all
(route '* '(. rest) (lambda (r) (values 'not-found #f))))
(define (match-path routes method path)
"Compile ROUTES (appending catch-all), match METHOD and PATH segments."
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
(let ((handler bindings (match-route compiled method path)))
(values handler bindings))))
(define-suite router-tests
(suite "match-route"
(test "literal path"
(define h (lambda (r) 'ok))
(define routes (list (route 'GET '("users" "list") h)))
(let ((handler bindings (match-path routes 'GET '("users" "list"))))
(is (eq? h handler))
(is (equal? '() bindings))))
(test "no match falls through to catch-all"
(define h (lambda (r) 'ok))
(define routes (list (route 'GET '("users") h)))
(let ((handler _bindings (match-path routes 'GET '("other"))))
(is (not (eq? h handler)))))
(test "capture segment"
(define h (lambda (r) 'ok))
(define routes (list (route 'GET '("users" id) h)))
(let ((_handler bindings (match-path routes 'GET '("users" "42"))))
(is (equal? "42" (assq-ref bindings 'id)))))
(test "wildcard rest"
(define h (lambda (r) 'ok))
(define routes (list (route 'GET '("files" . path) h)))
(let ((_handler bindings (match-path routes 'GET '("files" "a" "b"))))
(is (equal? '("a" "b") (assq-ref bindings 'path)))))
(test "predicate segment"
(define h (lambda (r) 'ok))
(define routes
(list (route 'GET `("items" (,string->number id)) h)))
(let ((handler _b (match-path routes 'GET '("items" "99"))))
(is (eq? h handler)))
(let ((handler _b (match-path routes 'GET '("items" "abc"))))
(is (not (eq? h handler)))))
(test "method filtering"
(define h-get (lambda (r) 'get))
(define h-post (lambda (r) 'post))
(define routes (list (route 'GET '("x") h-get)
(route 'POST '("x") h-post)))
(let ((handler _b (match-path routes 'GET '("x"))))
(is (eq? h-get handler)))
(let ((handler _b (match-path routes 'POST '("x"))))
(is (eq? h-post handler))))
(test "multi-method route"
(define h (lambda (r) 'ok))
(define routes (list (route '(GET HEAD) '("x") h)))
(let ((handler _b (match-path routes 'GET '("x"))))
(is (eq? h handler)))
(let ((handler _b (match-path routes 'HEAD '("x"))))
(is (eq? h handler)))
(let ((handler _b (match-path routes 'POST '("x"))))
(is (not (eq? h handler))))))
(suite "route-group"
(test "prefix nesting"
(define h (lambda (r) 'ok))
(define routes
(list (route-group '("api")
(route 'GET '("users") h #:name 'api-users))))
(let ((handler _b (match-path routes 'GET '("api" "users"))))
(is (eq? h handler)))
(let ((handler _b (match-path routes 'GET '("users"))))
(is (not (eq? h handler))))))
(suite "wrap-routes"
(test "wrapper ordering"
;; First wrapper = outermost = runs first on request.
;; We verify by building a call log.
(define log '())
(define (make-wrapper tag)
(lambda (handler)
(lambda (request)
(set! log (append log (list tag)))
(handler request))))
(define h (lambda (r) (set! log (append log '(handler))) 'ok))
(define r (route 'GET '("x") h))
(wrap-routes (list r) (make-wrapper 'a) (make-wrapper 'b))
((route-handler r) 'fake-request)
(is (equal? '(a b handler) log))))
(suite "find-allowed-methods"
(test "returns methods for path-matched routes"
(define routes
(list (route 'GET '("users") identity)
(route 'POST '("users") identity)))
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
(is (equal? '(POST GET)
(find-allowed-methods compiled '("users"))))))
(test "returns empty for unmatched path"
(define routes (list (route 'GET '("users") identity)))
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
(is (equal? '() (find-allowed-methods compiled '("other"))))))
(test "collects from multi-method routes"
(define routes (list (route '(GET HEAD) '("x") identity)
(route 'POST '("x") identity)))
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
(is (equal? '(POST HEAD GET)
(find-allowed-methods compiled '("x"))))))
(test "deduplicates methods"
(define routes (list (route 'GET '("x") identity)
(route 'GET '("x") identity)))
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
(is (equal? '(GET)
(find-allowed-methods compiled '("x"))))))
(test "excludes catch-all from scan"
(let ((compiled _rr (compile-routes (list catch-all))))
(is (equal? '() (find-allowed-methods compiled '("anything")))))))
(suite "path-for"
(test "simple and parameterised"
(define grp
(route-group '()
(route 'GET '("users") identity #:name 'users)
(route 'GET '("users" id) identity #:name 'user)))
(let ((_compiled rr
(compile-routes (list grp catch-all))))
(parameterize ((current-reverse-routes rr))
(is (equal? "/users" (path-for grp 'users)))
(is (equal? "/users/42" (path-for grp 'user '((id . "42"))))))))
(test "query and fragment"
(define grp
(route-group '()
(route 'GET '("search") identity #:name 'search)))
(let ((_compiled rr
(compile-routes (list grp catch-all))))
(parameterize ((current-reverse-routes rr))
(is (equal? "/search?q=hello"
(path-for grp 'search '() #:query '((q . "hello")))))
(is (equal? "/search#top"
(path-for grp 'search '() #:fragment "top"))))))
(test "scoped lookup in group"
(define grp
(route-group '("api") #:name 'api
(route 'GET '("items") identity #:name 'items)))
(let ((_compiled rr
(compile-routes (list grp catch-all))))
(parameterize ((current-reverse-routes rr))
(is (equal? "/api/items" (path-for grp 'items))))))))
(run-tests router-tests)

102
tests/test-templating.scm Normal file
View file

@ -0,0 +1,102 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; test-templating.scm — Tests for (safsaf templating)
(use-modules (tests support)
(safsaf templating)
(htmlprag)
(srfi srfi-71)
(web response))
(define (render shtml)
"Write SHTML via write-shtml-as-html/streaming and capture as a string."
(call-with-output-string
(lambda (port) (write-shtml-as-html/streaming shtml port))))
(define-suite templating-tests
(suite "write-shtml-as-html/streaming"
(test "pure static shtml"
(let ((out (render '(p "hello"))))
(is (string-contains out "<p>hello</p>"))))
(test "single proc slot"
(let ((out (render
`(div ,(lambda (port) (display "dynamic" port))))))
(is (string-contains out "dynamic"))))
(test "multiple slots in order"
(let ((out (render
`(div ,(lambda (port) (display "AAA" port))
,(lambda (port) (display "BBB" port))))))
(let ((a (string-contains out "AAA"))
(b (string-contains out "BBB")))
(is a)
(is b)
(is (< a b)))))
(test "static content between slots preserved"
(let ((out (render
`(div ,(lambda (port) (display "X" port))
(hr)
,(lambda (port) (display "Y" port))))))
(is (string-contains out "<hr"))))
(test "nested element with proc child"
(let ((out (render
`(html (body ,(lambda (port) (display "inner" port)))))))
(is (string-contains out "<body>inner</body>"))))
(test "attributes preserved"
(let ((out (render
`(div (@ (class "box"))
,(lambda (port) (display "content" port))))))
(is (string-contains out "class=\"box\""))
(is (string-contains out "content"))))
(test "*TOP* with procs"
(let ((out (render
`(*TOP*
(*DECL* DOCTYPE html)
(html (body ,(lambda (port) (display "hi" port))))))))
(is (string-contains out "<!DOCTYPE html>"))
(is (string-contains out "hi"))))
(test "proc can write shtml via htmlprag"
(let ((out (render
`(div ,(lambda (port)
(write-shtml-as-html '(p "from-proc") port))))))
(is (string-contains out "<p>from-proc</p>")))))
(suite "streaming-html-response"
(test "returns response and writer"
(let ((resp body (streaming-html-response '(p "hi"))))
(is (= 200 (response-code resp)))
(is (procedure? body))
(is (equal? '(text/html (charset . "utf-8"))
(assq-ref (response-headers resp) 'content-type)))))
(test "body writes shtml with procs"
(let ((resp body (streaming-html-response
`(div ,(lambda (port) (display "streamed" port))))))
(let ((out (call-with-output-string body)))
(is (string-contains out "streamed")))))))
(run-tests templating-tests)

85
tests/test-utils.scm Normal file
View file

@ -0,0 +1,85 @@
;; Safsaf, a Guile web framework
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Lesser General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Lesser General Public License for more details.
;;
;; You should have received a copy of the GNU Lesser General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.
;;; test-utils.scm — Tests for (safsaf utils)
(use-modules (tests support)
(safsaf utils)
(web request)
(web uri))
(define* (make-request method path #:key (headers '()))
(build-request (string->uri (string-append "http://localhost" path))
#:method method
#:headers headers))
(define-suite utils-tests
(suite "parse-query-string"
(test "parses key=value pairs"
(let* ((req (make-request 'GET "/?foo=bar&baz=qux"))
(qs (parse-query-string req)))
(is (equal? '(("foo" . "bar") ("baz" . "qux")) qs))))
(test "returns empty list when no query string"
(let* ((req (make-request 'GET "/"))
(qs (parse-query-string req)))
(is (null? qs))))
(test "decodes URL-encoded values"
(let* ((req (make-request 'GET "/?name=hello%20world"))
(qs (parse-query-string req)))
(is (equal? "hello world" (assoc-ref qs "name")))))
(test "handles key without value"
(let* ((req (make-request 'GET "/?flag"))
(qs (parse-query-string req)))
(is (equal? '(("flag" . "")) qs))))
(test "handles multiple values for same key"
(let* ((req (make-request 'GET "/?x=1&x=2"))
(qs (parse-query-string req)))
(is (= 2 (length qs)))
(is (equal? "1" (assoc-ref qs "x"))))))
(suite "request-cookies"
(test "parses cookie header"
(let* ((req (make-request 'GET "/"
#:headers '((cookie . (("a" . "1")
("b" . "2"))))))
(cookies (request-cookies req)))
(is (equal? '(("a" . "1") ("b" . "2")) cookies))))
(test "returns empty list when no cookie header"
(let* ((req (make-request 'GET "/"))
(cookies (request-cookies req)))
(is (null? cookies)))))
(suite "request-cookie-ref"
(test "returns cookie value by name"
(let ((req (make-request 'GET "/"
#:headers '((cookie . (("sid" . "abc123")))))))
(is (equal? "abc123" (request-cookie-ref req "sid")))))
(test "returns #f when cookie not found"
(let ((req (make-request 'GET "/")))
(is (not (request-cookie-ref req "missing")))))
(test "returns default when cookie not found"
(let ((req (make-request 'GET "/")))
(is (equal? "fallback" (request-cookie-ref req "missing" "fallback")))))))
(run-tests utils-tests)