diff --git a/README.md b/README.md
index 8e8d3be..4550654 100644
--- a/README.md
+++ b/README.md
@@ -90,41 +90,51 @@ All servers are designed for testing purposes:
Both servers provide the following endpoints:
-| Endpoint | Method | Description |
-| ------------------- | ------ | ------------------------------- |
-| `/` | GET | Web UI |
-| `/doc` | GET | API documentation (Markdown) |
-| `/api/entries` | GET | List all entries (newest first) |
-| `/api/entries/{id}` | GET | Get specific entry |
-| `/api/entries/{id}` | DELETE | Delete specific entry |
-| `/api/entries` | DELETE | Clear all entries |
-| `/api/stats` | GET | Store statistics |
-| `/api/events` | GET | Server-Sent Events stream |
-| `/health` | GET | Health check |
+| Endpoint | Method | Description |
+| ------------------- | ------ | -------------------------------- |
+| `/` | GET | Web UI |
+| `/doc` | GET | API documentation (Markdown) |
+| `/api/entries` | GET | List all entries (newest first) |
+| `/api/entries/{id}` | GET | Get specific entry |
+| `/api/entries/{id}` | DELETE | Delete specific entry |
+| `/api/entries` | DELETE | Clear all entries |
+| `/api/stats` | GET | Store statistics |
+| `/api/count` | GET | Count entries matching filter |
+| `/api/await` | GET | Wait for entries matching filter |
+| `/api/events` | GET | Server-Sent Events stream |
+| `/health` | GET | Health check |
### Filtering
`GET /api/entries` supports query parameters for filtering:
-| Parameter | Description | trap-smtp | trap-webhook |
-| ---------------- | ------------------------------- | --------- | ------------ |
-| `from` | Contains match on sender | ✓ | |
-| `to` | Contains match on any recipient | ✓ | |
-| `subject` | Contains match on subject | ✓ | |
-| `method` | Exact match on HTTP method | | ✓ |
-| `path` | Contains match on request path | | ✓ |
-| `query` | Contains match on query string | | ✓ |
-| `body` | Contains match on body | ✓ | ✓ |
-| `jsonpath` | JSONPath expression for body | ✓ | ✓ |
-| `jsonpath_value` | Expected value at JSONPath | ✓ | ✓ |
-| `content_type` | Contains match on Content-Type | | ✓ |
-| `header` | Header name to check | ✓ | ✓ |
-| `header_value` | Header value contains match | ✓ | ✓ |
-| `host` | Contains match on Host header | | ✓ |
-| `since` | ReceivedAt after (RFC3339) | ✓ | ✓ |
-| `until` | ReceivedAt before (RFC3339) | ✓ | ✓ |
-| `limit` | Maximum number of results | ✓ | ✓ |
-| `offset` | Skip first N results | ✓ | ✓ |
+| Parameter | Description | trap-smtp | trap-webhook |
+| -------------------- | ------------------------------- | --------- | ------------ |
+| `from` | Contains match on sender | ✓ | |
+| `from_regex` | Regex match on sender | ✓ | |
+| `to` | Contains match on any recipient | ✓ | |
+| `to_regex` | Regex match on any recipient | ✓ | |
+| `subject` | Contains match on subject | ✓ | |
+| `subject_regex` | Regex match on subject | ✓ | |
+| `method` | Exact match on HTTP method | | ✓ |
+| `path` | Contains match on request path | | ✓ |
+| `path_regex` | Regex match on request path | | ✓ |
+| `query` | Contains match on query string | | ✓ |
+| `query_regex` | Regex match on query string | | ✓ |
+| `body` | Contains match on body | ✓ | ✓ |
+| `body_regex` | Regex match on body | ✓ | ✓ |
+| `jsonpath` | JSONPath expression for body | ✓ | ✓ |
+| `jsonpath_value` | Expected value at JSONPath | ✓ | ✓ |
+| `content_type` | Contains match on Content-Type | | ✓ |
+| `content_type_regex` | Regex match on Content-Type | | ✓ |
+| `header` | Header name to check | ✓ | ✓ |
+| `header_value` | Header value contains match | ✓ | ✓ |
+| `host` | Contains match on Host header | | ✓ |
+| `host_regex` | Regex match on Host header | | ✓ |
+| `since` | ReceivedAt after (RFC3339) | ✓ | ✓ |
+| `until` | ReceivedAt before (RFC3339) | ✓ | ✓ |
+| `limit` | Maximum number of results | ✓ | ✓ |
+| `offset` | Skip first N results | ✓ | ✓ |
### trap-smtp specific
diff --git a/trap-smtp/go.mod b/trap-smtp/go.mod
index 0217868..4dce616 100644
--- a/trap-smtp/go.mod
+++ b/trap-smtp/go.mod
@@ -1,4 +1,4 @@
-module github.com/probitas-test/state-servers/state-smtp
+module github.com/probitas-test/state-servers/trap-smtp
go 1.25
diff --git a/trap-smtp/handlers/api.go b/trap-smtp/handlers/api.go
index e69ebb4..2b0081d 100644
--- a/trap-smtp/handlers/api.go
+++ b/trap-smtp/handlers/api.go
@@ -2,15 +2,17 @@ package handlers
import (
"encoding/json"
+ "fmt"
"mime"
"net/http"
+ "regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
- "github.com/probitas-test/state-servers/state-smtp/store"
+ "github.com/probitas-test/state-servers/trap-smtp/store"
)
var emailStore *store.Store
@@ -22,7 +24,11 @@ func SetStore(s *store.Store) {
// ListEntriesHandler returns stored email entries with optional filtering
func ListEntriesHandler(w http.ResponseWriter, r *http.Request) {
- filter := parseEmailFilter(r)
+ filter, err := parseEmailFilter(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
entries := emailStore.ListWithFilter(filter)
w.Header().Set("Content-Type", "application/json")
@@ -32,7 +38,7 @@ func ListEntriesHandler(w http.ResponseWriter, r *http.Request) {
}
// parseEmailFilter extracts filter parameters from the request
-func parseEmailFilter(r *http.Request) *store.EmailFilter {
+func parseEmailFilter(r *http.Request) (*store.EmailFilter, error) {
q := r.URL.Query()
filter := &store.EmailFilter{
@@ -46,6 +52,26 @@ func parseEmailFilter(r *http.Request) *store.EmailFilter {
HeaderValue: q.Get("header_value"),
}
+ // Parse regex filters
+ regexFields := []struct {
+ param string
+ dest **regexp.Regexp
+ }{
+ {"from_regex", &filter.FromRegex},
+ {"to_regex", &filter.ToRegex},
+ {"subject_regex", &filter.SubjectRegex},
+ {"body_regex", &filter.BodyRegex},
+ }
+ for _, rf := range regexFields {
+ if v := q.Get(rf.param); v != "" {
+ re, err := regexp.Compile(v)
+ if err != nil {
+ return nil, fmt.Errorf("invalid %s: %w", rf.param, err)
+ }
+ *rf.dest = re
+ }
+ }
+
// Parse time filters
if since := q.Get("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
@@ -70,7 +96,7 @@ func parseEmailFilter(r *http.Request) *store.EmailFilter {
}
}
- return filter
+ return filter, nil
}
// GetEntryHandler returns a specific entry by ID
diff --git a/trap-smtp/handlers/await.go b/trap-smtp/handlers/await.go
new file mode 100644
index 0000000..63d0d6c
--- /dev/null
+++ b/trap-smtp/handlers/await.go
@@ -0,0 +1,122 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/probitas-test/state-servers/trap-smtp/store"
+)
+
+// AwaitHandler blocks until the specified number of entries match the filter,
+// or the timeout is reached. Returns matched entries on success, 408 on timeout.
+//
+// Query parameters:
+// - count: minimum number of matching entries to wait for (default: 1)
+// - timeout: maximum wait duration, e.g. "5s", "500ms" (default: 10s)
+// - All email filter parameters (from, to, subject, body, etc.)
+func AwaitHandler(w http.ResponseWriter, r *http.Request) {
+ filter, err := parseAwaitFilter(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ count := 1
+ if c := r.URL.Query().Get("count"); c != "" {
+ n, err := strconv.Atoi(c)
+ if err != nil || n <= 0 {
+ http.Error(w, "invalid 'count' parameter: must be a positive integer", http.StatusBadRequest)
+ return
+ }
+ count = n
+ }
+
+ timeout := 10 * time.Second
+ if t := r.URL.Query().Get("timeout"); t != "" {
+ d, err := time.ParseDuration(t)
+ if err != nil || d <= 0 {
+ http.Error(w, "invalid 'timeout' parameter: must be a positive duration (e.g. '5s', '500ms')", http.StatusBadRequest)
+ return
+ }
+ timeout = d
+ }
+
+ // Subscribe before checking existing entries to avoid missing entries
+ // added between the check and subscribe.
+ ch := emailStore.Subscribe()
+ defer emailStore.Unsubscribe(ch)
+
+ // Check existing entries
+ entries := emailStore.ListWithFilter(filter)
+ if len(entries) >= count {
+ resp, err := json.Marshal(entries)
+ if err != nil {
+ http.Error(w, "Failed to encode entries", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(resp); err != nil {
+ // Unable to write response; nothing more we can do here.
+ return
+ }
+ return
+ }
+
+ // Wait for new entries
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+
+ for {
+ select {
+ case _, ok := <-ch:
+ if !ok {
+ http.Error(w, "Store closed", http.StatusInternalServerError)
+ return
+ }
+ entries = emailStore.ListWithFilter(filter)
+ if len(entries) >= count {
+ resp, err := json.Marshal(entries)
+ if err != nil {
+ http.Error(w, "Failed to encode entries", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(resp); err != nil {
+ // Unable to write response; nothing more we can do here.
+ return
+ }
+ return
+ }
+ case <-timer.C:
+ resp, err := json.Marshal(map[string]interface{}{
+ "error": "timeout",
+ "matched": len(emailStore.ListWithFilter(filter)),
+ "expected": count,
+ })
+ if err != nil {
+ http.Error(w, "Failed to encode timeout response", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusRequestTimeout)
+ _, _ = w.Write(resp)
+ return
+ case <-r.Context().Done():
+ return
+ }
+ }
+}
+
+// parseAwaitFilter extracts filter parameters without pagination (limit/offset).
+func parseAwaitFilter(r *http.Request) (*store.EmailFilter, error) {
+ filter, err := parseEmailFilter(r)
+ if err != nil {
+ return nil, err
+ }
+ // Clear pagination fields (not supported by await)
+ filter.Limit = 0
+ filter.Offset = 0
+ return filter, nil
+}
diff --git a/trap-smtp/handlers/await_test.go b/trap-smtp/handlers/await_test.go
new file mode 100644
index 0000000..f72a637
--- /dev/null
+++ b/trap-smtp/handlers/await_test.go
@@ -0,0 +1,224 @@
+package handlers_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/probitas-test/state-servers/trap-smtp/handlers"
+ "github.com/probitas-test/state-servers/trap-smtp/store"
+)
+
+func setupAwaitRouter() (*chi.Mux, *store.Store) {
+ s := store.New(100, 0)
+ handlers.SetStore(s)
+
+ r := chi.NewRouter()
+ r.Get("/api/await", handlers.AwaitHandler)
+ return r, s
+}
+
+func TestAwaitHandler_ExistingEntries(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ s.Add(&store.EmailEntry{From: "test@example.com", Subject: "Hello"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=1s&from=test@example.com", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.EmailEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("len(entries) = %d, want 1", len(entries))
+ }
+}
+
+func TestAwaitHandler_WaitsForEntry(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ s.Add(&store.EmailEntry{From: "test@example.com"})
+ }()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=2s&from=test@example.com", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.EmailEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("len(entries) = %d, want 1", len(entries))
+ }
+}
+
+func TestAwaitHandler_Timeout(t *testing.T) {
+ r, _ := setupAwaitRouter()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=200ms&from=nonexistent@example.com", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusRequestTimeout {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusRequestTimeout)
+ }
+}
+
+func TestAwaitHandler_WithFilter(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ // Add non-matching entry
+ s.Add(&store.EmailEntry{From: "other@example.com", Subject: "Other"})
+
+ // Add matching entry after delay
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ s.Add(&store.EmailEntry{From: "target@example.com", Subject: "Target"})
+ }()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=2s&from=target@example.com", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.EmailEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("len(entries) = %d, want 1", len(entries))
+ }
+ if entries[0].From != "target@example.com" {
+ t.Errorf("From = %q, want %q", entries[0].From, "target@example.com")
+ }
+}
+
+func TestAwaitHandler_MultipleCount(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ s.Add(&store.EmailEntry{From: "test@example.com", Subject: "First"})
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ s.Add(&store.EmailEntry{From: "test@example.com", Subject: "Second"})
+ }()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=2&timeout=2s&from=test@example.com", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.EmailEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 2 {
+ t.Errorf("len(entries) = %d, want 2", len(entries))
+ }
+}
+
+func TestAwaitHandler_DefaultCount(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ s.Add(&store.EmailEntry{From: "test@example.com"})
+
+ // No count param - should default to 1
+ req := httptest.NewRequest(http.MethodGet, "/api/await?timeout=1s&from=test@example.com", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.EmailEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("len(entries) = %d, want 1", len(entries))
+ }
+}
+
+func TestAwaitHandler_InvalidCount(t *testing.T) {
+ r, _ := setupAwaitRouter()
+
+ tests := []struct {
+ name string
+ count string
+ }{
+ {"negative", "-1"},
+ {"zero", "0"},
+ {"non-integer", "abc"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ url := "/api/await?count=" + tt.count + "&timeout=1s"
+
+ req := httptest.NewRequest(http.MethodGet, url, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want %d for count=%s", w.Code, http.StatusBadRequest, tt.count)
+ }
+ })
+ }
+}
+
+func TestAwaitHandler_InvalidTimeout(t *testing.T) {
+ r, _ := setupAwaitRouter()
+
+ tests := []struct {
+ name string
+ timeout string
+ }{
+ {"negative", "-1s"},
+ {"zero", "0s"},
+ {"malformed", "invalid"},
+ {"no-unit", "5"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ url := "/api/await?count=1&timeout=" + tt.timeout
+
+ req := httptest.NewRequest(http.MethodGet, url, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want %d for timeout=%s", w.Code, http.StatusBadRequest, tt.timeout)
+ }
+ })
+ }
+}
diff --git a/trap-smtp/handlers/count.go b/trap-smtp/handlers/count.go
new file mode 100644
index 0000000..ed839dc
--- /dev/null
+++ b/trap-smtp/handlers/count.go
@@ -0,0 +1,33 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// CountHandler returns the number of entries matching the filter criteria.
+func CountHandler(w http.ResponseWriter, r *http.Request) {
+ filter, err := parseEmailFilter(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ // Ignore pagination for counting
+ filter.Limit = 0
+ filter.Offset = 0
+
+ entries := emailStore.ListWithFilter(filter)
+
+ resp, err := json.Marshal(map[string]interface{}{
+ "count": len(entries),
+ })
+ if err != nil {
+ http.Error(w, "failed to marshal count response", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(resp); err != nil {
+ // Unable to write response; nothing more we can do here.
+ return
+ }
+}
diff --git a/trap-smtp/handlers/count_test.go b/trap-smtp/handlers/count_test.go
new file mode 100644
index 0000000..28c4920
--- /dev/null
+++ b/trap-smtp/handlers/count_test.go
@@ -0,0 +1,141 @@
+package handlers_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/probitas-test/state-servers/trap-smtp/handlers"
+ "github.com/probitas-test/state-servers/trap-smtp/store"
+)
+
+func setupCountRouter() (*chi.Mux, *store.Store) {
+ s := store.New(100, 0)
+ handlers.SetStore(s)
+
+ r := chi.NewRouter()
+ r.Get("/api/count", handlers.CountHandler)
+ return r, s
+}
+
+func TestCountHandler_Empty(t *testing.T) {
+ r, _ := setupCountRouter()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 0 {
+ t.Errorf("count = %v, want 0", resp["count"])
+ }
+}
+
+func TestCountHandler_NoFilter(t *testing.T) {
+ r, s := setupCountRouter()
+
+ s.Add(&store.EmailEntry{From: "a@example.com"})
+ s.Add(&store.EmailEntry{From: "b@example.com"})
+ s.Add(&store.EmailEntry{From: "c@example.com"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 3 {
+ t.Errorf("count = %v, want 3", resp["count"])
+ }
+}
+
+func TestCountHandler_WithFilter(t *testing.T) {
+ r, s := setupCountRouter()
+
+ s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Hello"})
+ s.Add(&store.EmailEntry{From: "bob@example.com", Subject: "Hello"})
+ s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Goodbye"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count?from=alice", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 2 {
+ t.Errorf("count = %v, want 2", resp["count"])
+ }
+}
+
+func TestCountHandler_MultipleFilters(t *testing.T) {
+ r, s := setupCountRouter()
+
+ s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Hello"})
+ s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Goodbye"})
+ s.Add(&store.EmailEntry{From: "bob@example.com", Subject: "Hello"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count?from=alice&subject=Hello", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 1 {
+ t.Errorf("count = %v, want 1", resp["count"])
+ }
+}
+
+func TestCountHandler_NoMatch(t *testing.T) {
+ r, s := setupCountRouter()
+
+ s.Add(&store.EmailEntry{From: "alice@example.com"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count?from=nonexistent", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 0 {
+ t.Errorf("count = %v, want 0", resp["count"])
+ }
+}
diff --git a/trap-smtp/handlers/doc/api.md b/trap-smtp/handlers/doc/api.md
index 455351c..1d2a6be 100644
--- a/trap-smtp/handlers/doc/api.md
+++ b/trap-smtp/handlers/doc/api.md
@@ -58,13 +58,18 @@ List all stored email entries with optional filtering.
| `jsonpath_value` | string | Expected value at JSONPath |
| `header` | string | Header name to check |
| `header_value` | string | Header value contains match |
+| `from_regex` | string | Regex match on sender address |
+| `to_regex` | string | Regex match on any recipient |
+| `subject_regex` | string | Regex match on subject |
+| `body_regex` | string | Regex match on body |
| `since` | string | ReceivedAt after (RFC3339 format) |
| `until` | string | ReceivedAt before (RFC3339 format) |
| `limit` | int | Maximum number of results |
| `offset` | int | Skip first N results |
> **Note:** All filters use AND logic. Empty filters return all entries
-> (backward compatible).
+> (backward compatible). Regex filters use Go's `regexp` syntax. Invalid regex
+> returns 400 Bad Request.
**Request:**
@@ -238,6 +243,106 @@ curl "http://localhost:8080/api/stats"
}
```
+### GET /api/count
+
+Get the number of entries matching the filter criteria. Useful for quick
+assertions without fetching full entry data.
+
+**Query Parameters:**
+
+Same filter parameters as `GET /api/entries` (`from`, `from_regex`, `to`,
+`to_regex`, `subject`, `subject_regex`, `body`, `body_regex`, `jsonpath`,
+`jsonpath_value`, `header`, `header_value`, `since`, `until`). Pagination
+parameters (`limit`, `offset`) are ignored.
+
+**Request:**
+
+```bash
+# Count all entries
+curl "http://localhost:8080/api/count"
+
+# Count emails from a specific sender
+curl "http://localhost:8080/api/count?from=test@example.com"
+
+# Count with multiple filters
+curl "http://localhost:8080/api/count?from=alice&subject=welcome"
+```
+
+**Response:**
+
+```json
+{
+ "count": 3
+}
+```
+
+### GET /api/await
+
+Block until the specified number of entries match the filter criteria, or the
+timeout is reached. This eliminates the need for polling or `sleep` in tests.
+
+**Query Parameters:**
+
+| Parameter | Type | Default | Description |
+| --------- | ------ | ------- | -------------------------------------------- |
+| `count` | int | `1` | Minimum number of matching entries to return |
+| `timeout` | string | `10s` | Maximum wait duration (Go duration format) |
+
+All email filter parameters (`from`, `to`, `subject`, `body`, `from_regex`,
+`to_regex`, `subject_regex`, `body_regex`, `jsonpath`, `jsonpath_value`,
+`header`, `header_value`, `since`, `until`) are also supported. Pagination
+parameters (`limit`, `offset`) are not supported.
+
+**Request:**
+
+```bash
+# Wait for 1 email from a specific sender (up to 5 seconds)
+curl "http://localhost:8080/api/await?from=test@example.com&timeout=5s"
+
+# Wait for 2 emails matching a subject pattern
+curl "http://localhost:8080/api/await?subject=welcome&count=2&timeout=10s"
+```
+
+**Success Response (200):**
+
+Returns all matching entries when the count threshold is met.
+
+```json
+[
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "received_at": "2025-01-27T10:30:00Z",
+ "from": "test@example.com",
+ "to": ["recipient@example.com"],
+ "subject": "Welcome",
+ "body": "Hello!"
+ }
+]
+```
+
+**Timeout Response (408):**
+
+```json
+{
+ "error": "timeout",
+ "matched": 0,
+ "expected": 1
+}
+```
+
+**Test Example (using curl in a CI pipeline):**
+
+```bash
+# 1. Send an email to trap-smtp
+swaks --to test@example.com --from sender@example.com \
+ --server localhost:2525 --header "Subject: Order #123"
+
+# 2. Wait for the email to arrive and verify
+curl -sf "http://localhost:8080/api/await?subject=Order&timeout=5s" | \
+ jq '.[0].subject'
+# Output: "Order #123"
+```
+
### GET /api/events
Server-Sent Events (SSE) stream for real-time email notifications.
@@ -399,6 +504,7 @@ with smtplib.SMTP('localhost', 2525) as smtp:
| Field | Type | Description |
| -------------- | ------------------- | ------------------------------ |
| `id` | string | Unique identifier (UUID) |
+| `seq` | int64 | Monotonically increasing seq# |
| `received_at` | string (RFC3339) | Timestamp when email received |
| `from` | string | Sender address (envelope) |
| `to` | []string | Recipient addresses (envelope) |
@@ -408,4 +514,19 @@ with smtplib.SMTP('localhost', 2525) as smtp:
| `content_type` | string | Content-Type header |
| `headers` | map[string][]string | All email headers |
| `body` | string | Email body content |
+| `html_body` | string | HTML version (if available) |
+| `text_body` | string | Plain text version |
| `raw_email` | string | Complete raw email |
+| `attachments` | []Attachment | File attachments |
+
+### Attachment
+
+| Field | Type | Description |
+| -------------- | ------ | ------------------------------------------- |
+| `id` | string | Unique identifier for download |
+| `filename` | string | Original filename |
+| `content_type` | string | MIME type |
+| `size` | int | Size in bytes |
+| `sha256` | string | SHA-256 hex digest of the binary data |
+| `content_id` | string | Content-ID for inline images (cid:xxx) |
+| `is_inline` | bool | True if inline attachment (e.g., cid image) |
diff --git a/trap-smtp/handlers/handlers_test.go b/trap-smtp/handlers/handlers_test.go
index 7ad948a..72697ce 100644
--- a/trap-smtp/handlers/handlers_test.go
+++ b/trap-smtp/handlers/handlers_test.go
@@ -10,8 +10,8 @@ import (
"github.com/go-chi/chi/v5"
- "github.com/probitas-test/state-servers/state-smtp/handlers"
- "github.com/probitas-test/state-servers/state-smtp/store"
+ "github.com/probitas-test/state-servers/trap-smtp/handlers"
+ "github.com/probitas-test/state-servers/trap-smtp/store"
)
func setupTestRouter() (*chi.Mux, *store.Store) {
@@ -197,6 +197,43 @@ func TestStatsHandler(t *testing.T) {
}
}
+func TestListEntriesHandler_RegexFilter(t *testing.T) {
+ r, s := setupTestRouter()
+
+ s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Hello"})
+ s.Add(&store.EmailEntry{From: "bob@test.com", Subject: "Welcome"})
+ s.Add(&store.EmailEntry{From: "alice@other.com", Subject: "Re: Hello"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/entries?from_regex=^alice@", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.EmailEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 2 {
+ t.Errorf("len(entries) = %d, want 2", len(entries))
+ }
+}
+
+func TestListEntriesHandler_InvalidRegex(t *testing.T) {
+ r, _ := setupTestRouter()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/entries?subject_regex=[invalid", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
+ }
+}
+
func TestUIHandler(t *testing.T) {
r, _ := setupTestRouter()
diff --git a/trap-smtp/main.go b/trap-smtp/main.go
index 0bd30cf..792c10e 100644
--- a/trap-smtp/main.go
+++ b/trap-smtp/main.go
@@ -9,9 +9,9 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
- "github.com/probitas-test/state-servers/state-smtp/handlers"
- smtpbackend "github.com/probitas-test/state-servers/state-smtp/smtp"
- "github.com/probitas-test/state-servers/state-smtp/store"
+ "github.com/probitas-test/state-servers/trap-smtp/handlers"
+ smtpbackend "github.com/probitas-test/state-servers/trap-smtp/smtp"
+ "github.com/probitas-test/state-servers/trap-smtp/store"
)
func main() {
@@ -74,6 +74,8 @@ func startHTTPServer(cfg *Config) {
r.Delete("/entries", handlers.ClearEntriesHandler)
r.Get("/stats", handlers.StatsHandler)
r.Get("/events", handlers.SSEHandler)
+ r.Get("/await", handlers.AwaitHandler)
+ r.Get("/count", handlers.CountHandler)
})
// API documentation
diff --git a/trap-smtp/smtp/backend.go b/trap-smtp/smtp/backend.go
index 658a8ad..0fa15c8 100644
--- a/trap-smtp/smtp/backend.go
+++ b/trap-smtp/smtp/backend.go
@@ -2,8 +2,10 @@ package smtp
import (
"bytes"
+ "crypto/sha256"
"encoding/base64"
"errors"
+ "fmt"
"io"
"mime"
"mime/multipart"
@@ -15,7 +17,7 @@ import (
"github.com/emersion/go-smtp"
"github.com/google/uuid"
- "github.com/probitas-test/state-servers/state-smtp/store"
+ "github.com/probitas-test/state-servers/trap-smtp/store"
)
// AuthConfig holds authentication settings
@@ -308,6 +310,7 @@ func parseMultipart(body []byte, contentType string, result *parsedBody) {
Filename: filename,
ContentType: partContentType,
Size: len(decodedData),
+ Sha256: fmt.Sprintf("%x", sha256.Sum256(decodedData)),
ContentID: contentID,
IsInline: isInline && contentID != "",
Data: decodedData,
diff --git a/trap-smtp/smtp/backend_test.go b/trap-smtp/smtp/backend_test.go
index 0096f03..bb232fe 100644
--- a/trap-smtp/smtp/backend_test.go
+++ b/trap-smtp/smtp/backend_test.go
@@ -1,14 +1,17 @@
package smtp_test
import (
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
"strings"
"testing"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
- "github.com/probitas-test/state-servers/state-smtp/smtp"
- "github.com/probitas-test/state-servers/state-smtp/store"
+ "github.com/probitas-test/state-servers/trap-smtp/smtp"
+ "github.com/probitas-test/state-servers/trap-smtp/store"
)
// noAuth returns an AuthConfig with no credentials (open relay mode)
@@ -576,3 +579,105 @@ func TestSession_MultipartBase64Encoded(t *testing.T) {
t.Errorf("Body should contain decoded Japanese text, got: %q", entry.Body)
}
}
+
+func TestSession_AttachmentSha256(t *testing.T) {
+ s := store.New(100, 0)
+ backend := smtp.NewBackend(s, noAuth())
+
+ session, _ := backend.NewSession(nil)
+
+ _ = session.Mail("sender@example.com", &gosmtp.MailOptions{})
+ _ = session.Rcpt("recipient@example.com", &gosmtp.RcptOptions{})
+
+ // Known attachment content and its SHA-256
+ attachmentContent := []byte("Hello, World!")
+ encodedContent := base64.StdEncoding.EncodeToString(attachmentContent)
+ expectedHash := fmt.Sprintf("%x", sha256.Sum256(attachmentContent))
+
+ emailContent := "From: sender@example.com\r\n" +
+ "To: recipient@example.com\r\n" +
+ "Subject: Attachment Test\r\n" +
+ "Content-Type: multipart/mixed; boundary=\"boundary-attach\"\r\n" +
+ "\r\n" +
+ "--boundary-attach\r\n" +
+ "Content-Type: text/plain; charset=utf-8\r\n" +
+ "\r\n" +
+ "Email body\r\n" +
+ "--boundary-attach\r\n" +
+ "Content-Type: application/octet-stream\r\n" +
+ "Content-Disposition: attachment; filename=\"test.bin\"\r\n" +
+ "Content-Transfer-Encoding: base64\r\n" +
+ "\r\n" +
+ encodedContent + "\r\n" +
+ "--boundary-attach--\r\n"
+
+ err := session.Data(strings.NewReader(emailContent))
+ if err != nil {
+ t.Fatalf("Data() error = %v", err)
+ }
+
+ entries := s.List()
+ entry := entries[0]
+
+ if len(entry.Attachments) != 1 {
+ t.Fatalf("len(Attachments) = %d, want 1", len(entry.Attachments))
+ }
+
+ att := entry.Attachments[0]
+ if att.Sha256 != expectedHash {
+ t.Errorf("Sha256 = %q, want %q", att.Sha256, expectedHash)
+ }
+ if att.Filename != "test.bin" {
+ t.Errorf("Filename = %q, want %q", att.Filename, "test.bin")
+ }
+}
+
+func TestSession_AttachmentSha256_InlineImage(t *testing.T) {
+ s := store.New(100, 0)
+ backend := smtp.NewBackend(s, noAuth())
+
+ session, _ := backend.NewSession(nil)
+
+ _ = session.Mail("sender@example.com", &gosmtp.MailOptions{})
+ _ = session.Rcpt("recipient@example.com", &gosmtp.RcptOptions{})
+
+ // Fake 1x1 PNG pixel data
+ imageData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ encodedImage := base64.StdEncoding.EncodeToString(imageData)
+ expectedHash := fmt.Sprintf("%x", sha256.Sum256(imageData))
+
+ emailContent := "From: sender@example.com\r\n" +
+ "To: recipient@example.com\r\n" +
+ "Subject: Inline Image Test\r\n" +
+ "Content-Type: multipart/related; boundary=\"boundary-inline\"\r\n" +
+ "\r\n" +
+ "--boundary-inline\r\n" +
+ "Content-Type: text/html; charset=utf-8\r\n" +
+ "\r\n" +
+ "
\r\n" +
+ "--boundary-inline\r\n" +
+ "Content-Type: image/png\r\n" +
+ "Content-Disposition: inline; filename=\"pixel.png\"\r\n" +
+ "Content-Id: \r\n" +
+ "Content-Transfer-Encoding: base64\r\n" +
+ "\r\n" +
+ encodedImage + "\r\n" +
+ "--boundary-inline--\r\n"
+
+ err := session.Data(strings.NewReader(emailContent))
+ if err != nil {
+ t.Fatalf("Data() error = %v", err)
+ }
+
+ entries := s.List()
+ entry := entries[0]
+
+ if len(entry.Attachments) != 1 {
+ t.Fatalf("len(Attachments) = %d, want 1", len(entry.Attachments))
+ }
+
+ att := entry.Attachments[0]
+ if att.Sha256 != expectedHash {
+ t.Errorf("Sha256 = %q, want %q", att.Sha256, expectedHash)
+ }
+}
diff --git a/trap-smtp/state-smtp b/trap-smtp/state-smtp
deleted file mode 100755
index 08071d0..0000000
Binary files a/trap-smtp/state-smtp and /dev/null differ
diff --git a/trap-smtp/store/filter.go b/trap-smtp/store/filter.go
index 6d0184c..91b2f00 100644
--- a/trap-smtp/store/filter.go
+++ b/trap-smtp/store/filter.go
@@ -3,6 +3,7 @@ package store
import (
"encoding/json"
"fmt"
+ "regexp"
"strings"
"time"
@@ -23,6 +24,12 @@ type EmailFilter struct {
Until *time.Time // ReceivedAt before
Limit int // Max results (0 = no limit)
Offset int // Skip first N results
+
+ // Regex filters (compiled regular expressions)
+ FromRegex *regexp.Regexp // Regex match on sender
+ ToRegex *regexp.Regexp // Regex match on any recipient
+ SubjectRegex *regexp.Regexp // Regex match on subject
+ BodyRegex *regexp.Regexp // Regex match on body
}
// IsEmpty returns true if no filter criteria are set
@@ -34,7 +41,11 @@ func (f *EmailFilter) IsEmpty() bool {
f.JSONPath == "" &&
f.Header == "" &&
f.Since == nil &&
- f.Until == nil
+ f.Until == nil &&
+ f.FromRegex == nil &&
+ f.ToRegex == nil &&
+ f.SubjectRegex == nil &&
+ f.BodyRegex == nil
}
// ListWithFilter returns entries matching the filter criteria
@@ -61,6 +72,9 @@ func matchesEmailFilter(entry *EmailEntry, filter *EmailFilter) bool {
if filter.From != "" && !containsIgnoreCase(entry.From, filter.From) {
return false
}
+ if filter.FromRegex != nil && !filter.FromRegex.MatchString(entry.From) {
+ return false
+ }
// To filter (any recipient must match)
if filter.To != "" {
@@ -75,16 +89,34 @@ func matchesEmailFilter(entry *EmailEntry, filter *EmailFilter) bool {
return false
}
}
+ if filter.ToRegex != nil {
+ found := false
+ for _, to := range entry.To {
+ if filter.ToRegex.MatchString(to) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
// Subject filter
if filter.Subject != "" && !containsIgnoreCase(entry.Subject, filter.Subject) {
return false
}
+ if filter.SubjectRegex != nil && !filter.SubjectRegex.MatchString(entry.Subject) {
+ return false
+ }
// Body filter
if filter.Body != "" && !containsIgnoreCase(entry.Body, filter.Body) {
return false
}
+ if filter.BodyRegex != nil && !filter.BodyRegex.MatchString(entry.Body) {
+ return false
+ }
// JSONPath filter
if filter.JSONPath != "" {
diff --git a/trap-smtp/store/filter_test.go b/trap-smtp/store/filter_test.go
index 4f0cdfe..e744b2d 100644
--- a/trap-smtp/store/filter_test.go
+++ b/trap-smtp/store/filter_test.go
@@ -1,6 +1,7 @@
package store
import (
+ "regexp"
"testing"
"time"
)
@@ -187,6 +188,41 @@ func TestStore_ListWithFilter(t *testing.T) {
filter: &EmailFilter{From: "nonexistent"},
wantCount: 0,
},
+ {
+ name: "filter by from regex",
+ filter: &EmailFilter{FromRegex: regexp.MustCompile(`^alice@`)},
+ wantCount: 1,
+ wantIDs: []string{"1"},
+ },
+ {
+ name: "filter by to regex (any recipient)",
+ filter: &EmailFilter{ToRegex: regexp.MustCompile(`(admin|support)@`)},
+ wantCount: 1,
+ wantIDs: []string{"2"},
+ },
+ {
+ name: "filter by subject regex",
+ filter: &EmailFilter{SubjectRegex: regexp.MustCompile(`^Re:`)},
+ wantCount: 1,
+ wantIDs: []string{"3"},
+ },
+ {
+ name: "filter by body regex",
+ filter: &EmailFilter{BodyRegex: regexp.MustCompile(`"event":"\w+"`)},
+ wantCount: 1,
+ wantIDs: []string{"2"},
+ },
+ {
+ name: "regex combined with contains filter (AND logic)",
+ filter: &EmailFilter{From: "example.com", SubjectRegex: regexp.MustCompile(`^Hello`)},
+ wantCount: 1,
+ wantIDs: []string{"1"},
+ },
+ {
+ name: "regex no match",
+ filter: &EmailFilter{SubjectRegex: regexp.MustCompile(`^Goodbye`)},
+ wantCount: 0,
+ },
}
for _, tt := range tests {
diff --git a/trap-smtp/store/store.go b/trap-smtp/store/store.go
index 50269b0..6184abd 100644
--- a/trap-smtp/store/store.go
+++ b/trap-smtp/store/store.go
@@ -13,6 +13,7 @@ type Attachment struct {
Filename string `json:"filename"` // Original filename
ContentType string `json:"content_type"` // MIME type
Size int `json:"size"` // Size in bytes
+ Sha256 string `json:"sha256"` // SHA-256 hash of the binary data
ContentID string `json:"content_id"` // Content-ID for inline images (cid:xxx)
IsInline bool `json:"is_inline"` // True if inline attachment
Data []byte `json:"-"` // Binary data (not serialized to JSON)
@@ -21,6 +22,7 @@ type Attachment struct {
// EmailEntry represents a single received email
type EmailEntry struct {
ID string `json:"id"`
+ Seq int64 `json:"seq"`
ReceivedAt time.Time `json:"received_at"`
// SMTP envelope
@@ -49,6 +51,7 @@ type Store struct {
mu sync.RWMutex
entries map[string]*EmailEntry
order []string // maintains insertion order for FIFO eviction
+ nextSeq int64
maxEntries int
ttl time.Duration
listeners map[chan *EmailEntry]struct{}
@@ -92,6 +95,8 @@ func (s *Store) Add(entry *EmailEntry) string {
delete(s.entries, oldestID)
}
+ s.nextSeq++
+ entry.Seq = s.nextSeq
s.entries[entry.ID] = entry
s.order = append(s.order, entry.ID)
diff --git a/trap-smtp/store/store_test.go b/trap-smtp/store/store_test.go
index 0443e78..aaff6d3 100644
--- a/trap-smtp/store/store_test.go
+++ b/trap-smtp/store/store_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"time"
- "github.com/probitas-test/state-servers/state-smtp/store"
+ "github.com/probitas-test/state-servers/trap-smtp/store"
)
func TestStore_Add_And_Get(t *testing.T) {
@@ -204,6 +204,59 @@ func TestStore_Subscribe_ReceivesNewEntries(t *testing.T) {
}
}
+func TestStore_Seq_MonotonicallyIncreasing(t *testing.T) {
+ t.Parallel()
+
+ s := store.New(100, 0)
+
+ s.Add(&store.EmailEntry{Subject: "First"})
+ s.Add(&store.EmailEntry{Subject: "Second"})
+ s.Add(&store.EmailEntry{Subject: "Third"})
+
+ list := s.List() // newest first
+
+ if list[0].Seq != 3 {
+ t.Errorf("list[0].Seq = %d, want 3", list[0].Seq)
+ }
+ if list[1].Seq != 2 {
+ t.Errorf("list[1].Seq = %d, want 2", list[1].Seq)
+ }
+ if list[2].Seq != 1 {
+ t.Errorf("list[2].Seq = %d, want 1", list[2].Seq)
+ }
+}
+
+func TestStore_Seq_StartsAtOne(t *testing.T) {
+ t.Parallel()
+
+ s := store.New(100, 0)
+
+ s.Add(&store.EmailEntry{Subject: "First"})
+
+ entry, _ := s.Get(s.List()[0].ID)
+ if entry.Seq != 1 {
+ t.Errorf("first entry Seq = %d, want 1", entry.Seq)
+ }
+}
+
+func TestStore_Seq_SurvivesEviction(t *testing.T) {
+ t.Parallel()
+
+ s := store.New(2, 0) // max 2 entries
+
+ s.Add(&store.EmailEntry{Subject: "1"}) // seq=1, will be evicted
+ s.Add(&store.EmailEntry{Subject: "2"}) // seq=2
+ s.Add(&store.EmailEntry{Subject: "3"}) // seq=3, evicts "1"
+
+ list := s.List() // newest first
+ if list[0].Seq != 3 {
+ t.Errorf("list[0].Seq = %d, want 3", list[0].Seq)
+ }
+ if list[1].Seq != 2 {
+ t.Errorf("list[1].Seq = %d, want 2", list[1].Seq)
+ }
+}
+
func TestStore_ConcurrentAccess(t *testing.T) {
t.Parallel()
diff --git a/trap-webhook/go.mod b/trap-webhook/go.mod
index 17173f9..a058849 100644
--- a/trap-webhook/go.mod
+++ b/trap-webhook/go.mod
@@ -1,4 +1,4 @@
-module github.com/probitas-test/state-servers/state-webhook
+module github.com/probitas-test/state-servers/trap-webhook
go 1.25
diff --git a/trap-webhook/handlers/api.go b/trap-webhook/handlers/api.go
index 64ecac0..6f77669 100644
--- a/trap-webhook/handlers/api.go
+++ b/trap-webhook/handlers/api.go
@@ -2,18 +2,24 @@ package handlers
import (
"encoding/json"
+ "fmt"
"net/http"
+ "regexp"
"strconv"
"time"
"github.com/go-chi/chi/v5"
- "github.com/probitas-test/state-servers/state-webhook/store"
+ "github.com/probitas-test/state-servers/trap-webhook/store"
)
// ListEntriesHandler returns stored webhook entries with optional filtering
func ListEntriesHandler(w http.ResponseWriter, r *http.Request) {
- filter := parseWebhookFilter(r)
+ filter, err := parseWebhookFilter(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
entries := webhookStore.ListWithFilter(filter)
w.Header().Set("Content-Type", "application/json")
@@ -23,7 +29,7 @@ func ListEntriesHandler(w http.ResponseWriter, r *http.Request) {
}
// parseWebhookFilter extracts filter parameters from the request
-func parseWebhookFilter(r *http.Request) *store.WebhookFilter {
+func parseWebhookFilter(r *http.Request) (*store.WebhookFilter, error) {
q := r.URL.Query()
filter := &store.WebhookFilter{
@@ -39,6 +45,27 @@ func parseWebhookFilter(r *http.Request) *store.WebhookFilter {
Host: q.Get("host"),
}
+ // Parse regex filters
+ regexFields := []struct {
+ param string
+ dest **regexp.Regexp
+ }{
+ {"path_regex", &filter.PathRegex},
+ {"query_regex", &filter.QueryRegex},
+ {"body_regex", &filter.BodyRegex},
+ {"content_type_regex", &filter.ContentTypeRegex},
+ {"host_regex", &filter.HostRegex},
+ }
+ for _, rf := range regexFields {
+ if v := q.Get(rf.param); v != "" {
+ re, err := regexp.Compile(v)
+ if err != nil {
+ return nil, fmt.Errorf("invalid %s: %w", rf.param, err)
+ }
+ *rf.dest = re
+ }
+ }
+
// Parse time filters
if since := q.Get("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
@@ -63,7 +90,7 @@ func parseWebhookFilter(r *http.Request) *store.WebhookFilter {
}
}
- return filter
+ return filter, nil
}
// GetEntryHandler returns a specific entry by ID
diff --git a/trap-webhook/handlers/await.go b/trap-webhook/handlers/await.go
new file mode 100644
index 0000000..bcda29e
--- /dev/null
+++ b/trap-webhook/handlers/await.go
@@ -0,0 +1,122 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/probitas-test/state-servers/trap-webhook/store"
+)
+
+// AwaitHandler blocks until the specified number of entries match the filter,
+// or the timeout is reached. Returns matched entries on success, 408 on timeout.
+//
+// Query parameters:
+// - count: minimum number of matching entries to wait for (default: 1)
+// - timeout: maximum wait duration, e.g. "5s", "500ms" (default: 10s)
+// - All webhook filter parameters (method, path, body, etc.)
+func AwaitHandler(w http.ResponseWriter, r *http.Request) {
+ filter, err := parseAwaitFilter(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ count := 1
+ if c := r.URL.Query().Get("count"); c != "" {
+ n, err := strconv.Atoi(c)
+ if err != nil || n <= 0 {
+ http.Error(w, "invalid 'count' parameter: must be a positive integer", http.StatusBadRequest)
+ return
+ }
+ count = n
+ }
+
+ timeout := 10 * time.Second
+ if t := r.URL.Query().Get("timeout"); t != "" {
+ d, err := time.ParseDuration(t)
+ if err != nil || d <= 0 {
+ http.Error(w, "invalid 'timeout' parameter: must be a positive duration (e.g. '5s', '500ms')", http.StatusBadRequest)
+ return
+ }
+ timeout = d
+ }
+
+ // Subscribe before checking existing entries to avoid missing entries
+ // added between the check and subscribe.
+ ch := webhookStore.Subscribe()
+ defer webhookStore.Unsubscribe(ch)
+
+ // Check existing entries
+ entries := webhookStore.ListWithFilter(filter)
+ if len(entries) >= count {
+ resp, err := json.Marshal(entries)
+ if err != nil {
+ http.Error(w, "Failed to encode entries", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(resp); err != nil {
+ // Unable to write response; nothing more we can do here.
+ return
+ }
+ return
+ }
+
+ // Wait for new entries
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+
+ for {
+ select {
+ case _, ok := <-ch:
+ if !ok {
+ http.Error(w, "Store closed", http.StatusInternalServerError)
+ return
+ }
+ entries = webhookStore.ListWithFilter(filter)
+ if len(entries) >= count {
+ resp, err := json.Marshal(entries)
+ if err != nil {
+ http.Error(w, "Failed to encode entries", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(resp); err != nil {
+ // Unable to write response; nothing more we can do here.
+ return
+ }
+ return
+ }
+ case <-timer.C:
+ resp, err := json.Marshal(map[string]interface{}{
+ "error": "timeout",
+ "matched": len(webhookStore.ListWithFilter(filter)),
+ "expected": count,
+ })
+ if err != nil {
+ http.Error(w, "Failed to encode timeout response", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusRequestTimeout)
+ _, _ = w.Write(resp)
+ return
+ case <-r.Context().Done():
+ return
+ }
+ }
+}
+
+// parseAwaitFilter extracts filter parameters without pagination (limit/offset).
+func parseAwaitFilter(r *http.Request) (*store.WebhookFilter, error) {
+ filter, err := parseWebhookFilter(r)
+ if err != nil {
+ return nil, err
+ }
+ // Clear pagination fields (not supported by await)
+ filter.Limit = 0
+ filter.Offset = 0
+ return filter, nil
+}
diff --git a/trap-webhook/handlers/await_test.go b/trap-webhook/handlers/await_test.go
new file mode 100644
index 0000000..ae9a1ea
--- /dev/null
+++ b/trap-webhook/handlers/await_test.go
@@ -0,0 +1,224 @@
+package handlers_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/probitas-test/state-servers/trap-webhook/handlers"
+ "github.com/probitas-test/state-servers/trap-webhook/store"
+)
+
+func setupAwaitRouter() (*chi.Mux, *store.Store) {
+ s := store.New(100, 0)
+ handlers.SetStore(s)
+
+ r := chi.NewRouter()
+ r.Get("/api/await", handlers.AwaitHandler)
+ return r, s
+}
+
+func TestAwaitHandler_ExistingEntries(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=1s&path=test", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.WebhookEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("len(entries) = %d, want 1", len(entries))
+ }
+}
+
+func TestAwaitHandler_WaitsForEntry(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"})
+ }()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=2s&path=test", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.WebhookEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("len(entries) = %d, want 1", len(entries))
+ }
+}
+
+func TestAwaitHandler_Timeout(t *testing.T) {
+ r, _ := setupAwaitRouter()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=200ms&path=nonexistent", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusRequestTimeout {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusRequestTimeout)
+ }
+}
+
+func TestAwaitHandler_WithFilter(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ // Add non-matching entry
+ s.Add(&store.WebhookEntry{Method: "GET", Path: "/webhook/other"})
+
+ // Add matching entry after delay
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/target"})
+ }()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=2s&method=POST&path=target", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.WebhookEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("len(entries) = %d, want 1", len(entries))
+ }
+ if entries[0].Path != "/webhook/target" {
+ t.Errorf("Path = %q, want %q", entries[0].Path, "/webhook/target")
+ }
+}
+
+func TestAwaitHandler_MultipleCount(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"})
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"})
+ }()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/await?count=2&timeout=2s&path=test", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.WebhookEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 2 {
+ t.Errorf("len(entries) = %d, want 2", len(entries))
+ }
+}
+
+func TestAwaitHandler_DefaultCount(t *testing.T) {
+ r, s := setupAwaitRouter()
+
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"})
+
+ // No count param - should default to 1
+ req := httptest.NewRequest(http.MethodGet, "/api/await?timeout=1s&path=test", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.WebhookEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 1 {
+ t.Errorf("len(entries) = %d, want 1", len(entries))
+ }
+}
+
+func TestAwaitHandler_InvalidCount(t *testing.T) {
+ r, _ := setupAwaitRouter()
+
+ tests := []struct {
+ name string
+ count string
+ }{
+ {"negative", "-1"},
+ {"zero", "0"},
+ {"non-integer", "abc"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ url := "/api/await?count=" + tt.count + "&timeout=1s"
+
+ req := httptest.NewRequest(http.MethodGet, url, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want %d for count=%s", w.Code, http.StatusBadRequest, tt.count)
+ }
+ })
+ }
+}
+
+func TestAwaitHandler_InvalidTimeout(t *testing.T) {
+ r, _ := setupAwaitRouter()
+
+ tests := []struct {
+ name string
+ timeout string
+ }{
+ {"negative", "-1s"},
+ {"zero", "0s"},
+ {"malformed", "invalid"},
+ {"no-unit", "5"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ url := "/api/await?count=1&timeout=" + tt.timeout
+
+ req := httptest.NewRequest(http.MethodGet, url, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want %d for timeout=%s", w.Code, http.StatusBadRequest, tt.timeout)
+ }
+ })
+ }
+}
diff --git a/trap-webhook/handlers/count.go b/trap-webhook/handlers/count.go
new file mode 100644
index 0000000..01dc53c
--- /dev/null
+++ b/trap-webhook/handlers/count.go
@@ -0,0 +1,33 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// CountHandler returns the number of entries matching the filter criteria.
+func CountHandler(w http.ResponseWriter, r *http.Request) {
+ filter, err := parseWebhookFilter(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ // Ignore pagination for counting
+ filter.Limit = 0
+ filter.Offset = 0
+
+ entries := webhookStore.ListWithFilter(filter)
+
+ resp, err := json.Marshal(map[string]interface{}{
+ "count": len(entries),
+ })
+ if err != nil {
+ http.Error(w, "failed to marshal count response", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(resp); err != nil {
+ // Unable to write response; nothing more we can do here.
+ return
+ }
+}
diff --git a/trap-webhook/handlers/count_test.go b/trap-webhook/handlers/count_test.go
new file mode 100644
index 0000000..1bdbbb7
--- /dev/null
+++ b/trap-webhook/handlers/count_test.go
@@ -0,0 +1,141 @@
+package handlers_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/probitas-test/state-servers/trap-webhook/handlers"
+ "github.com/probitas-test/state-servers/trap-webhook/store"
+)
+
+func setupCountRouter() (*chi.Mux, *store.Store) {
+ s := store.New(100, 0)
+ handlers.SetStore(s)
+
+ r := chi.NewRouter()
+ r.Get("/api/count", handlers.CountHandler)
+ return r, s
+}
+
+func TestCountHandler_Empty(t *testing.T) {
+ r, _ := setupCountRouter()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 0 {
+ t.Errorf("count = %v, want 0", resp["count"])
+ }
+}
+
+func TestCountHandler_NoFilter(t *testing.T) {
+ r, s := setupCountRouter()
+
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/a"})
+ s.Add(&store.WebhookEntry{Method: "GET", Path: "/webhook/b"})
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/c"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 3 {
+ t.Errorf("count = %v, want 3", resp["count"])
+ }
+}
+
+func TestCountHandler_WithFilter(t *testing.T) {
+ r, s := setupCountRouter()
+
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/a"})
+ s.Add(&store.WebhookEntry{Method: "GET", Path: "/webhook/b"})
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/c"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count?method=POST", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 2 {
+ t.Errorf("count = %v, want 2", resp["count"])
+ }
+}
+
+func TestCountHandler_MultipleFilters(t *testing.T) {
+ r, s := setupCountRouter()
+
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/payment"})
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/notification"})
+ s.Add(&store.WebhookEntry{Method: "GET", Path: "/webhook/payment"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count?method=POST&path=payment", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 1 {
+ t.Errorf("count = %v, want 1", resp["count"])
+ }
+}
+
+func TestCountHandler_NoMatch(t *testing.T) {
+ r, s := setupCountRouter()
+
+ s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/count?method=DELETE", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp["count"].(float64) != 0 {
+ t.Errorf("count = %v, want 0", resp["count"])
+ }
+}
diff --git a/trap-webhook/handlers/doc/api.md b/trap-webhook/handlers/doc/api.md
index 4a3041d..0cfe6b6 100644
--- a/trap-webhook/handlers/doc/api.md
+++ b/trap-webhook/handlers/doc/api.md
@@ -67,25 +67,31 @@ List all stored webhook entries with optional filtering.
**Query Parameters:**
-| Parameter | Type | Description |
-| ---------------- | ------ | --------------------------------------------- |
-| `method` | string | Exact match on HTTP method (case-insensitive) |
-| `path` | string | Contains match on request path |
-| `query` | string | Contains match on query string |
-| `body` | string | Contains match on request body |
-| `jsonpath` | string | JSONPath expression for JSON body |
-| `jsonpath_value` | string | Expected value at JSONPath |
-| `content_type` | string | Contains match on Content-Type |
-| `header` | string | Header name to check |
-| `header_value` | string | Header value contains match |
-| `host` | string | Contains match on Host header |
-| `since` | string | ReceivedAt after (RFC3339 format) |
-| `until` | string | ReceivedAt before (RFC3339 format) |
-| `limit` | int | Maximum number of results |
-| `offset` | int | Skip first N results |
+| Parameter | Type | Description |
+| -------------------- | ------ | --------------------------------------------- |
+| `method` | string | Exact match on HTTP method (case-insensitive) |
+| `path` | string | Contains match on request path |
+| `query` | string | Contains match on query string |
+| `body` | string | Contains match on request body |
+| `jsonpath` | string | JSONPath expression for JSON body |
+| `jsonpath_value` | string | Expected value at JSONPath |
+| `content_type` | string | Contains match on Content-Type |
+| `header` | string | Header name to check |
+| `header_value` | string | Header value contains match |
+| `host` | string | Contains match on Host header |
+| `path_regex` | string | Regex match on request path |
+| `query_regex` | string | Regex match on query string |
+| `body_regex` | string | Regex match on request body |
+| `content_type_regex` | string | Regex match on Content-Type |
+| `host_regex` | string | Regex match on Host header |
+| `since` | string | ReceivedAt after (RFC3339 format) |
+| `until` | string | ReceivedAt before (RFC3339 format) |
+| `limit` | int | Maximum number of results |
+| `offset` | int | Skip first N results |
> **Note:** All filters use AND logic. Empty filters return all entries
-> (backward compatible).
+> (backward compatible). Regex filters use Go's `regexp` syntax. Invalid regex
+> returns 400 Bad Request.
**Request:**
@@ -243,6 +249,105 @@ curl "http://localhost:8080/api/stats"
}
```
+### GET /api/count
+
+Get the number of entries matching the filter criteria. Useful for quick
+assertions without fetching full entry data.
+
+**Query Parameters:**
+
+Same filter parameters as `GET /api/entries` (`method`, `path`, `query`, `body`,
+`jsonpath`, `jsonpath_value`, `content_type`, `header`, `header_value`, `host`,
+`path_regex`, `query_regex`, `body_regex`, `content_type_regex`, `host_regex`,
+`since`, `until`). Pagination parameters (`limit`, `offset`) are ignored.
+
+**Request:**
+
+```bash
+# Count all entries
+curl "http://localhost:8080/api/count"
+
+# Count POST requests
+curl "http://localhost:8080/api/count?method=POST"
+
+# Count with multiple filters
+curl "http://localhost:8080/api/count?method=POST&path=payment"
+```
+
+**Response:**
+
+```json
+{
+ "count": 3
+}
+```
+
+### GET /api/await
+
+Block until the specified number of entries match the filter criteria, or the
+timeout is reached. This eliminates the need for polling or `sleep` in tests.
+
+**Query Parameters:**
+
+| Parameter | Type | Default | Description |
+| --------- | ------ | ------- | -------------------------------------------- |
+| `count` | int | `1` | Minimum number of matching entries to return |
+| `timeout` | string | `10s` | Maximum wait duration (Go duration format) |
+
+All webhook filter parameters (`method`, `path`, `query`, `body`, `jsonpath`,
+`jsonpath_value`, `content_type`, `header`, `header_value`, `host`, `since`,
+`until`, `path_regex`, `query_regex`, `body_regex`, `content_type_regex`,
+`host_regex`) are also supported. Pagination parameters (`limit`, `offset`) are
+not supported.
+
+**Request:**
+
+```bash
+# Wait for 1 POST webhook (up to 5 seconds)
+curl "http://localhost:8080/api/await?method=POST&timeout=5s"
+
+# Wait for 2 webhooks to a specific path
+curl "http://localhost:8080/api/await?path=/webhook/payment&count=2&timeout=10s"
+```
+
+**Success Response (200):**
+
+Returns all matching entries when the count threshold is met.
+
+```json
+[
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "received_at": "2025-01-27T10:30:00Z",
+ "method": "POST",
+ "path": "/webhook/payment",
+ "body": "{\"event\": \"payment.completed\"}"
+ }
+]
+```
+
+**Timeout Response (408):**
+
+```json
+{
+ "error": "timeout",
+ "matched": 0,
+ "expected": 1
+}
+```
+
+**Test Example (using curl in a CI pipeline):**
+
+```bash
+# 1. Trigger your app (which sends a webhook to trap-webhook)
+curl -X POST http://my-app/process-order
+
+# 2. Wait for the webhook to arrive and verify
+curl -sf "http://localhost:8080/api/await?path=/webhook/payment&timeout=5s" | \
+ jq '.[0].body | fromjson | .event'
+# Output: "payment.completed"
+```
+
### GET /api/events
Server-Sent Events (SSE) stream for real-time webhook notifications.
@@ -305,6 +410,7 @@ The web UI provides:
| Field | Type | Description |
| -------------- | ------------------- | ------------------------------- |
| `id` | string | Unique identifier (UUID) |
+| `seq` | int64 | Monotonically increasing seq# |
| `received_at` | string (RFC3339) | Timestamp when request received |
| `method` | string | HTTP method (GET, POST, etc.) |
| `path` | string | Request path |
diff --git a/trap-webhook/handlers/handlers_test.go b/trap-webhook/handlers/handlers_test.go
index 0ca054f..70e8f15 100644
--- a/trap-webhook/handlers/handlers_test.go
+++ b/trap-webhook/handlers/handlers_test.go
@@ -10,8 +10,8 @@ import (
"github.com/go-chi/chi/v5"
- "github.com/probitas-test/state-servers/state-webhook/handlers"
- "github.com/probitas-test/state-servers/state-webhook/store"
+ "github.com/probitas-test/state-servers/trap-webhook/handlers"
+ "github.com/probitas-test/state-servers/trap-webhook/store"
)
func setupTestRouter() (*chi.Mux, *store.Store) {
@@ -225,6 +225,43 @@ func TestStatsHandler(t *testing.T) {
}
}
+func TestListEntriesHandler_RegexFilter(t *testing.T) {
+ r, s := setupTestRouter()
+
+ s.Add(&store.WebhookEntry{Path: "/api/users"})
+ s.Add(&store.WebhookEntry{Path: "/api/webhooks/events"})
+ s.Add(&store.WebhookEntry{Path: "/api/users/123"})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/entries?path_regex=^/api/users", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var entries []*store.WebhookEntry
+ if err := json.NewDecoder(w.Body).Decode(&entries); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(entries) != 2 {
+ t.Errorf("len(entries) = %d, want 2", len(entries))
+ }
+}
+
+func TestListEntriesHandler_InvalidRegex(t *testing.T) {
+ r, _ := setupTestRouter()
+
+ req := httptest.NewRequest(http.MethodGet, "/api/entries?path_regex=[invalid", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
+ }
+}
+
func TestUIHandler(t *testing.T) {
r := chi.NewRouter()
r.Get("/", handlers.UIHandler)
diff --git a/trap-webhook/handlers/webhook.go b/trap-webhook/handlers/webhook.go
index 0c76df4..7e1d499 100644
--- a/trap-webhook/handlers/webhook.go
+++ b/trap-webhook/handlers/webhook.go
@@ -4,7 +4,7 @@ import (
"io"
"net/http"
- "github.com/probitas-test/state-servers/state-webhook/store"
+ "github.com/probitas-test/state-servers/trap-webhook/store"
)
var webhookStore *store.Store
diff --git a/trap-webhook/main.go b/trap-webhook/main.go
index 19b7311..4d58d6c 100644
--- a/trap-webhook/main.go
+++ b/trap-webhook/main.go
@@ -7,8 +7,8 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
- "github.com/probitas-test/state-servers/state-webhook/handlers"
- "github.com/probitas-test/state-servers/state-webhook/store"
+ "github.com/probitas-test/state-servers/trap-webhook/handlers"
+ "github.com/probitas-test/state-servers/trap-webhook/store"
)
func main() {
@@ -33,6 +33,8 @@ func main() {
r.Delete("/entries", handlers.ClearEntriesHandler)
r.Get("/stats", handlers.StatsHandler)
r.Get("/events", handlers.SSEHandler)
+ r.Get("/await", handlers.AwaitHandler)
+ r.Get("/count", handlers.CountHandler)
})
// Webhook receiver - accepts any method on any path under /webhook/
diff --git a/trap-webhook/store/filter.go b/trap-webhook/store/filter.go
index 70dc263..8ff23fb 100644
--- a/trap-webhook/store/filter.go
+++ b/trap-webhook/store/filter.go
@@ -3,6 +3,7 @@ package store
import (
"encoding/json"
"fmt"
+ "regexp"
"strings"
"time"
@@ -25,6 +26,13 @@ type WebhookFilter struct {
Until *time.Time // ReceivedAt before
Limit int // Max results (0 = no limit)
Offset int // Skip first N results
+
+ // Regex filters (compiled regular expressions)
+ PathRegex *regexp.Regexp // Regex match on path
+ QueryRegex *regexp.Regexp // Regex match on query string
+ BodyRegex *regexp.Regexp // Regex match on body
+ ContentTypeRegex *regexp.Regexp // Regex match on content type
+ HostRegex *regexp.Regexp // Regex match on host
}
// IsEmpty returns true if no filter criteria are set
@@ -38,7 +46,12 @@ func (f *WebhookFilter) IsEmpty() bool {
f.Header == "" &&
f.Host == "" &&
f.Since == nil &&
- f.Until == nil
+ f.Until == nil &&
+ f.PathRegex == nil &&
+ f.QueryRegex == nil &&
+ f.BodyRegex == nil &&
+ f.ContentTypeRegex == nil &&
+ f.HostRegex == nil
}
// ListWithFilter returns entries matching the filter criteria
@@ -70,16 +83,25 @@ func matchesWebhookFilter(entry *WebhookEntry, filter *WebhookFilter) bool {
if filter.Path != "" && !containsIgnoreCase(entry.Path, filter.Path) {
return false
}
+ if filter.PathRegex != nil && !filter.PathRegex.MatchString(entry.Path) {
+ return false
+ }
// Query string filter
if filter.Query != "" && !containsIgnoreCase(entry.QueryString, filter.Query) {
return false
}
+ if filter.QueryRegex != nil && !filter.QueryRegex.MatchString(entry.QueryString) {
+ return false
+ }
// Body filter
if filter.Body != "" && !containsIgnoreCase(entry.Body, filter.Body) {
return false
}
+ if filter.BodyRegex != nil && !filter.BodyRegex.MatchString(entry.Body) {
+ return false
+ }
// JSONPath filter
if filter.JSONPath != "" {
@@ -92,6 +114,9 @@ func matchesWebhookFilter(entry *WebhookEntry, filter *WebhookFilter) bool {
if filter.ContentType != "" && !containsIgnoreCase(entry.ContentType, filter.ContentType) {
return false
}
+ if filter.ContentTypeRegex != nil && !filter.ContentTypeRegex.MatchString(entry.ContentType) {
+ return false
+ }
// Header filter
if filter.Header != "" {
@@ -104,6 +129,9 @@ func matchesWebhookFilter(entry *WebhookEntry, filter *WebhookFilter) bool {
if filter.Host != "" && !containsIgnoreCase(entry.Host, filter.Host) {
return false
}
+ if filter.HostRegex != nil && !filter.HostRegex.MatchString(entry.Host) {
+ return false
+ }
// Time range filter
if !matchTimeRange(entry.ReceivedAt, filter.Since, filter.Until) {
diff --git a/trap-webhook/store/filter_test.go b/trap-webhook/store/filter_test.go
index db39be9..1372c8b 100644
--- a/trap-webhook/store/filter_test.go
+++ b/trap-webhook/store/filter_test.go
@@ -1,6 +1,7 @@
package store
import (
+ "regexp"
"testing"
"time"
)
@@ -205,6 +206,47 @@ func TestStore_ListWithFilter(t *testing.T) {
filter: &WebhookFilter{Method: "DELETE"},
wantCount: 0,
},
+ {
+ name: "filter by path regex",
+ filter: &WebhookFilter{PathRegex: regexp.MustCompile(`^/api/users$`)},
+ wantCount: 2,
+ wantIDs: []string{"3", "1"},
+ },
+ {
+ name: "filter by path regex (webhooks only)",
+ filter: &WebhookFilter{PathRegex: regexp.MustCompile(`/webhooks/`)},
+ wantCount: 1,
+ wantIDs: []string{"2"},
+ },
+ {
+ name: "filter by body regex",
+ filter: &WebhookFilter{BodyRegex: regexp.MustCompile(`"event":"user\.\w+"`)},
+ wantCount: 1,
+ wantIDs: []string{"2"},
+ },
+ {
+ name: "filter by host regex",
+ filter: &WebhookFilter{HostRegex: regexp.MustCompile(`^(api|hooks)\.example\.com$`)},
+ wantCount: 3,
+ wantIDs: []string{"3", "2", "1"},
+ },
+ {
+ name: "filter by host regex (specific)",
+ filter: &WebhookFilter{HostRegex: regexp.MustCompile(`^hooks\.`)},
+ wantCount: 1,
+ wantIDs: []string{"2"},
+ },
+ {
+ name: "regex combined with contains filter (AND logic)",
+ filter: &WebhookFilter{Method: "POST", PathRegex: regexp.MustCompile(`/users$`)},
+ wantCount: 1,
+ wantIDs: []string{"3"},
+ },
+ {
+ name: "regex no match",
+ filter: &WebhookFilter{PathRegex: regexp.MustCompile(`^/nonexistent`)},
+ wantCount: 0,
+ },
}
for _, tt := range tests {
diff --git a/trap-webhook/store/store.go b/trap-webhook/store/store.go
index 2df261c..c6d56f7 100644
--- a/trap-webhook/store/store.go
+++ b/trap-webhook/store/store.go
@@ -10,6 +10,7 @@ import (
// WebhookEntry represents a single received webhook request
type WebhookEntry struct {
ID string `json:"id"`
+ Seq int64 `json:"seq"`
ReceivedAt time.Time `json:"received_at"`
Method string `json:"method"`
Path string `json:"path"`
@@ -26,6 +27,7 @@ type Store struct {
mu sync.RWMutex
entries map[string]*WebhookEntry
order []string // maintains insertion order for FIFO eviction
+ nextSeq int64
maxEntries int
ttl time.Duration
listeners map[chan *WebhookEntry]struct{}
@@ -69,6 +71,8 @@ func (s *Store) Add(entry *WebhookEntry) string {
delete(s.entries, oldestID)
}
+ s.nextSeq++
+ entry.Seq = s.nextSeq
s.entries[entry.ID] = entry
s.order = append(s.order, entry.ID)
diff --git a/trap-webhook/store/store_test.go b/trap-webhook/store/store_test.go
index 09617eb..482b813 100644
--- a/trap-webhook/store/store_test.go
+++ b/trap-webhook/store/store_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"time"
- "github.com/probitas-test/state-servers/state-webhook/store"
+ "github.com/probitas-test/state-servers/trap-webhook/store"
)
func TestStore_Add_And_Get(t *testing.T) {
@@ -204,6 +204,59 @@ func TestStore_Subscribe_ReceivesNewEntries(t *testing.T) {
}
}
+func TestStore_Seq_MonotonicallyIncreasing(t *testing.T) {
+ t.Parallel()
+
+ s := store.New(100, 0)
+
+ s.Add(&store.WebhookEntry{Path: "/first"})
+ s.Add(&store.WebhookEntry{Path: "/second"})
+ s.Add(&store.WebhookEntry{Path: "/third"})
+
+ list := s.List() // newest first
+
+ if list[0].Seq != 3 {
+ t.Errorf("list[0].Seq = %d, want 3", list[0].Seq)
+ }
+ if list[1].Seq != 2 {
+ t.Errorf("list[1].Seq = %d, want 2", list[1].Seq)
+ }
+ if list[2].Seq != 1 {
+ t.Errorf("list[2].Seq = %d, want 1", list[2].Seq)
+ }
+}
+
+func TestStore_Seq_StartsAtOne(t *testing.T) {
+ t.Parallel()
+
+ s := store.New(100, 0)
+
+ s.Add(&store.WebhookEntry{Path: "/first"})
+
+ entry, _ := s.Get(s.List()[0].ID)
+ if entry.Seq != 1 {
+ t.Errorf("first entry Seq = %d, want 1", entry.Seq)
+ }
+}
+
+func TestStore_Seq_SurvivesEviction(t *testing.T) {
+ t.Parallel()
+
+ s := store.New(2, 0) // max 2 entries
+
+ s.Add(&store.WebhookEntry{Path: "/1"}) // seq=1, will be evicted
+ s.Add(&store.WebhookEntry{Path: "/2"}) // seq=2
+ s.Add(&store.WebhookEntry{Path: "/3"}) // seq=3, evicts "/1"
+
+ list := s.List() // newest first
+ if list[0].Seq != 3 {
+ t.Errorf("list[0].Seq = %d, want 3", list[0].Seq)
+ }
+ if list[1].Seq != 2 {
+ t.Errorf("list[1].Seq = %d, want 2", list[1].Seq)
+ }
+}
+
func TestStore_ConcurrentAccess(t *testing.T) {
t.Parallel()