test(notify): add comprehensive tests for notification delivery (#79)
All checks were successful
check / check (push) Successful in 50s

## Summary

Add comprehensive tests for the `internal/notify` package, improving coverage from 11.1% to 80.0%.

Closes [issue #71](#71).

## What was added

### `delivery_test.go` — 28 new test functions

**Priority mapping tests:**
- `TestNtfyPriority` — all priority levels (error→urgent, warning→high, success→default, info→low, unknown→default)
- `TestSlackColor` — all color mappings including default fallback

**Request construction:**
- `TestNewRequest` — method, URL, host, headers, body
- `TestNewRequestPreservesContext` — context propagation

**ntfy delivery (`sendNtfy`):**
- `TestSendNtfyHeaders` — Title, Priority headers, POST body content
- `TestSendNtfyAllPriorities` — end-to-end header verification for all priority levels
- `TestSendNtfyClientError` — 403 returns `ErrNtfyFailed`
- `TestSendNtfyServerError` — 500 returns `ErrNtfyFailed`
- `TestSendNtfySuccess` — 200 OK succeeds
- `TestSendNtfyNetworkError` — transport failure handling

**Slack/Mattermost delivery (`sendSlack`):**
- `TestSendSlackPayloadFields` — JSON payload structure, Content-Type header, attachment fields
- `TestSendSlackAllColors` — color mapping for all priorities
- `TestSendSlackClientError` — 400 returns `ErrSlackFailed`
- `TestSendSlackServerError` — 502 returns `ErrSlackFailed`
- `TestSendSlackNetworkError` — transport failure handling

**`SendNotification` goroutine dispatch:**
- `TestSendNotificationAllEndpoints` — all three endpoints receive notifications concurrently
- `TestSendNotificationNoWebhooks` — no-op when no endpoints configured
- `TestSendNotificationNtfyOnly` — ntfy-only dispatch
- `TestSendNotificationSlackOnly` — slack-only dispatch
- `TestSendNotificationMattermostOnly` — mattermost-only dispatch
- `TestSendNotificationNtfyError` — error logging path (no panic)
- `TestSendNotificationSlackError` — error logging path (no panic)
- `TestSendNotificationMattermostError` — error logging path (no panic)

**Payload marshaling:**
- `TestSlackPayloadJSON` — round-trip marshal/unmarshal
- `TestSlackPayloadEmptyAttachments` — `omitempty` behavior

### `export_test.go` — test bridge

Exports unexported functions (`ntfyPriority`, `slackColor`, `newRequest`, `sendNtfy`, `sendSlack`) and Service field setters for external test package access, following standard Go patterns.

## Coverage

| Function | Before | After |
|---|---|---|
| `IsAllowedScheme` | 100% | 100% |
| `ValidateWebhookURL` | 100% | 100% |
| `newRequest` | 0% | 100% |
| `SendNotification` | 0% | 100% |
| `sendNtfy` | 0% | 100% |
| `ntfyPriority` | 0% | 100% |
| `sendSlack` | 0% | 94.1% |
| `slackColor` | 0% | 100% |
| **Total** | **11.1%** | **80.0%** |

The remaining 20% is the `New()` constructor (requires fx wiring) and one unreachable `json.Marshal` error path in `sendSlack`.

## Testing approach

- `httptest.Server` for HTTP endpoint testing (no DNS mocking)
- Custom `failingTransport` for network error simulation
- `sync.Mutex`-protected captures for concurrent goroutine verification
- All tests are parallel

`docker build .` passes 

<!-- session: agent:sdlc-manager:subagent:6158e09a-aba4-4778-89ca-c12b22014ccd -->

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #79
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #79.
This commit is contained in:
2026-03-04 11:26:31 +01:00
committed by Jeffrey Paul
parent c5bf16055e
commit 1843d09eb3
2 changed files with 1205 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
package notify
import (
"context"
"io"
"log/slog"
"net/http"
"net/url"
)
// NtfyPriority exports ntfyPriority for testing.
func NtfyPriority(priority string) string {
return ntfyPriority(priority)
}
// SlackColor exports slackColor for testing.
func SlackColor(priority string) string {
return slackColor(priority)
}
// NewRequestForTest exports newRequest for testing.
func NewRequestForTest(
ctx context.Context,
method string,
target *url.URL,
body io.Reader,
) *http.Request {
return newRequest(ctx, method, target, body)
}
// NewTestService creates a Service suitable for unit testing.
// It discards log output and uses the given transport.
func NewTestService(transport http.RoundTripper) *Service {
return &Service{
log: slog.New(slog.DiscardHandler),
transport: transport,
}
}
// SetNtfyURL sets the ntfy URL on a Service for testing.
func (svc *Service) SetNtfyURL(u *url.URL) {
svc.ntfyURL = u
}
// SetSlackWebhookURL sets the Slack webhook URL on a
// Service for testing.
func (svc *Service) SetSlackWebhookURL(u *url.URL) {
svc.slackWebhookURL = u
}
// SetMattermostWebhookURL sets the Mattermost webhook URL on
// a Service for testing.
func (svc *Service) SetMattermostWebhookURL(u *url.URL) {
svc.mattermostWebhookURL = u
}
// SendNtfy exports sendNtfy for testing.
func (svc *Service) SendNtfy(
ctx context.Context,
topicURL *url.URL,
title, message, priority string,
) error {
return svc.sendNtfy(ctx, topicURL, title, message, priority)
}
// SendSlack exports sendSlack for testing.
func (svc *Service) SendSlack(
ctx context.Context,
webhookURL *url.URL,
title, message, priority string,
) error {
return svc.sendSlack(
ctx, webhookURL, title, message, priority,
)
}