Files
onvif/event/stream/renew_test.go
Sebastian Norling a1fc7832ef refactor(event/stream): align source and test files 1:1 by concern
Previously stream.go was a 621-line monolith holding the Stream type,
SOAP plumbing, pull loop, renew loop, recreate logic and jitter. The
test side had grown five orphan files (renew_test.go, reconnect_test.go,
soap_test.go, jitter_test.go, coverage_test.go) with no matching source
files. The mismatch made it harder than necessary to find the code
that backed a given test.

This commit splits stream.go by concern so each source file has its
own test file alongside it. Files <100 LOC (errors, jitter) were folded
into their conceptual parents rather than left as fragments.

New layout — 8 source + 8 test + helpers (test utility) + doc:

  stream.go     <-> stream_test.go      Stream type, Options, lifecycle
  soap.go       <-> soap_test.go        SOAP plumbing + fault detection
  renew.go      <-> renew_test.go       Renew loop and absolute time
  reconnect.go  <-> reconnect_test.go   Pull loop, recreate, jitter
  decode.go     <-> decode_test.go      NotificationMessage -> Event
  types.go      <-> types_test.go       Event types + typed errors
  topics.go     <-> topics_test.go      Classifier table
  doc.go                                Package godoc landing page
  helpers_test.go                       waitFor (test-only utility)

Mergers
-------
* errors.go (typed error wrappers, 42 LOC) -> types.go. ErrPullFailed /
  ErrRenewFailed / ErrRecreateFailed are part of the type system, not a
  separate concern.
* jitter.go (40 LOC) -> reconnect.go. jitter is an implementation detail
  of attemptRecreate, used nowhere else.

Test distribution
-----------------
* coverage_test.go was a catch-all; tests moved to the file matching
  the function under test:
    - Close*, NewStream_*, FakeCaller_* -> stream_test.go
    - DisableReconnect_*, RecreateResets_*, PullPointMutation_* ->
      reconnect_test.go
    - Decode_*, ExtractState_* -> decode_test.go
* soap_test.go shed the two orphans that did not belong there:
    - TestRenew_SendsAbsoluteDateTimeNotDuration -> renew_test.go
    - TestClose_BoundedByTimeoutOnHungUnsubscribe -> stream_test.go
* errors_test.go's pure type tests -> types_test.go
* errors_test.go's Stream-integration tests -> reconnect_test.go
* jitter_test.go -> reconnect_test.go

No behaviour change. Test suite passes -race clean.
2026-05-21 15:14:40 +02:00

171 lines
4.8 KiB
Go

package stream
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// countSendSoapMatching counts how many recorded SendSoap calls have a
// body containing needle. Safe to call concurrently with the run loop.
func countSendSoapMatching(fc *fakeCaller, needle string) int {
fc.mu.Lock()
defer fc.mu.Unlock()
n := 0
for _, c := range fc.sendSoapCalls {
if strings.Contains(c[1], needle) {
n++
}
}
return n
}
func TestStream_RenewsSubscriptionBeforeExpiry(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 100 ms termination with 10 ms margin -> renew every ~90 ms.
s, err := newStream(ctx, fc, Options{
DeviceID: "cam-1",
InitialTermination: 100 * time.Millisecond,
RenewMargin: 10 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(500 * time.Millisecond)
var renewCount int
for time.Now().Before(deadline) {
renewCount = countSendSoapMatching(fc, "Renew")
if renewCount >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
assert.GreaterOrEqual(t, renewCount, 1, "expected at least one Renew SendSoap call within 500ms")
}
func TestStream_RenewSendsToSubscriptionEndpoint(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
InitialTermination: 80 * time.Millisecond,
RenewMargin: 10 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if countSendSoapMatching(fc, "Renew") >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
fc.mu.Lock()
defer fc.mu.Unlock()
var renewEndpoint string
for _, c := range fc.sendSoapCalls {
if strings.Contains(c[1], "Renew") {
renewEndpoint = c[0]
break
}
}
require.NotEmpty(t, renewEndpoint, "no Renew call found")
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", renewEndpoint,
"Renew must target the SubscriptionReference Address")
}
func TestStream_RenewMarginAppliesDefault(t *testing.T) {
o := defaultOptions()
assert.Equal(t, 10*time.Second, o.RenewMargin)
}
func TestStream_RenewErrorSurfacedOnErrorsChannel(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
// Defaults return empty pulls indefinitely so the pull loop is clean.
// Override defaultSendSoap on the fly to return a Renew error for
// any body that looks like a Renew. We do that by tagging the
// default response with an err, then resetting after capturing one.
// Simpler: just queue several explicit Renew-error responses; the
// fake's queue is consumed in FIFO and the pull body never matches
// 'Renew', so queued errors will land on the renew call only if
// queued before any pulls. To bias the order we drain via a custom
// default.
fc.mu.Lock()
fc.defaultSendSoap = fakeResp{err: errInjected{}}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
InitialTermination: 80 * time.Millisecond,
RenewMargin: 10 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
select {
case e := <-s.Errors():
assert.Contains(t, e.Error(), "injected")
case <-time.After(time.Second):
t.Fatal("expected an error on Errors channel from failing Renew/pull")
}
}
// errInjected is a sentinel error type so the test message has a stable
// substring without depending on a wrapped string match.
type errInjected struct{}
func (errInjected) Error() string { return "injected fake error" }
func TestRenew_SendsAbsoluteDateTimeNotDuration(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
InitialTermination: 30 * time.Millisecond,
RenewMargin: 5 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if countSendSoapMatching(fc, "Renew") >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
fc.mu.Lock()
defer fc.mu.Unlock()
var renewBody string
for _, c := range fc.sendSoapCalls {
if strings.Contains(c[1], "Renew") {
renewBody = c[1]
break
}
}
require.NotEmpty(t, renewBody, "no Renew call observed")
// Absolute form is "YYYY-MM-DDTHH:MM:SSZ" not "PTnS".
assert.NotContains(t, renewBody, "PT", "Renew should not send relative duration; some firmwares reject it")
assert.Regexp(t, `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`, renewBody, "Renew should send absolute RFC3339 UTC")
}