Skip to content

Commit 58a44e9

Browse files
committed
test: cover pre-1.0 gaps and drop dead skip scaffolding
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent ae5178a commit 58a44e9

8 files changed

Lines changed: 643 additions & 83 deletions

File tree

source/memsource/batch_test.go

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package memsource_test
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"sync"
9+
"testing"
10+
"time"
11+
12+
"github.com/stablekernel/crucible/source"
13+
"github.com/stablekernel/crucible/source/memsource"
14+
)
15+
16+
// TestHarness_RunBatch_DrivesBatchLane drives the harness batch lane across a
17+
// range of size/count combinations and asserts batch contents, the per-call
18+
// grouping, and a clean drain of every queued message. It exercises the
19+
// RunBatch/RunBatchFor harness entry points from the memsource package itself.
20+
func TestHarness_RunBatch_DrivesBatchLane(t *testing.T) {
21+
t.Parallel()
22+
tests := []struct {
23+
name string
24+
size int
25+
count int
26+
wantSizes []int
27+
}{
28+
{name: "single full batch", size: 3, count: 3, wantSizes: []int{3}},
29+
{name: "two full batches", size: 2, count: 4, wantSizes: []int{2, 2}},
30+
{name: "trailing partial batch", size: 3, count: 5, wantSizes: []int{3, 2}},
31+
{name: "size exceeds count", size: 10, count: 4, wantSizes: []int{4}},
32+
}
33+
for _, tt := range tests {
34+
tt := tt
35+
t.Run(tt.name, func(t *testing.T) {
36+
t.Parallel()
37+
msgs := make([]memsource.Msg, tt.count)
38+
for i := range msgs {
39+
// One key keeps every message in a single ordered lane so the
40+
// batch sizes are deterministic.
41+
msgs[i] = memsource.Msg{Key: "k", Value: []byte(fmt.Sprintf("%d", i))}
42+
}
43+
44+
var (
45+
mu sync.Mutex
46+
gotSizes []int
47+
gotValues []string
48+
)
49+
h := memsource.NewHarness(t,
50+
[]source.Option{source.WithBatch(tt.size, 0)},
51+
msgs...,
52+
)
53+
h.RunBatch(func(_ context.Context, ms []source.Message) []source.Result {
54+
res := make([]source.Result, len(ms))
55+
mu.Lock()
56+
gotSizes = append(gotSizes, len(ms))
57+
for i, m := range ms {
58+
gotValues = append(gotValues, string(m.Value()))
59+
res[i] = source.Ack()
60+
}
61+
mu.Unlock()
62+
return res
63+
})
64+
65+
if !equalInts(gotSizes, tt.wantSizes) {
66+
t.Fatalf("batch sizes = %v, want %v", gotSizes, tt.wantSizes)
67+
}
68+
// The single lane preserves arrival order across the whole run.
69+
wantValues := make([]string, tt.count)
70+
for i := range wantValues {
71+
wantValues[i] = fmt.Sprintf("%d", i)
72+
}
73+
if !equalStrings(gotValues, wantValues) {
74+
t.Fatalf("delivered values = %v, want %v", gotValues, wantValues)
75+
}
76+
h.AssertCounts(memsource.Counts{Acked: tt.count})
77+
h.AssertSettled(tt.count)
78+
})
79+
}
80+
}
81+
82+
// TestHarness_RunBatch_SettleByResult proves the harness settles each message in
83+
// a batch by its own result, not a single batch-wide outcome: a mixed result
84+
// slice acks, naks, and terms the corresponding positions.
85+
func TestHarness_RunBatch_SettleByResult(t *testing.T) {
86+
t.Parallel()
87+
h := memsource.NewHarness(t,
88+
[]source.Option{source.WithBatch(3, 0)},
89+
memsource.Msg{Key: "k", Value: []byte("0")},
90+
memsource.Msg{Key: "k", Value: []byte("1")},
91+
memsource.Msg{Key: "k", Value: []byte("2")},
92+
)
93+
h.RunBatch(func(_ context.Context, ms []source.Message) []source.Result {
94+
res := make([]source.Result, len(ms))
95+
for i := range ms {
96+
switch i {
97+
case 0:
98+
res[i] = source.Ack()
99+
case 1:
100+
res[i] = source.Nak(fmt.Errorf("retry %d", i))
101+
default:
102+
res[i] = source.Term(fmt.Errorf("poison %d", i))
103+
}
104+
}
105+
return res
106+
})
107+
h.AssertCounts(memsource.Counts{Acked: 1, Nak: 1, Term: 1})
108+
}
109+
110+
// TestHarness_RunBatchFor_DrainsOnTimeout confirms RunBatchFor passes an explicit
111+
// timeout through and still drains every queued message under it.
112+
func TestHarness_RunBatchFor_DrainsOnTimeout(t *testing.T) {
113+
t.Parallel()
114+
h := memsource.NewHarness(t,
115+
[]source.Option{source.WithBatch(2, 0)},
116+
memsource.Msg{Key: "k"},
117+
memsource.Msg{Key: "k"},
118+
memsource.Msg{Key: "k"},
119+
)
120+
h.RunBatchFor(2*time.Second, func(_ context.Context, ms []source.Message) []source.Result {
121+
res := make([]source.Result, len(ms))
122+
for i := range ms {
123+
res[i] = source.Ack()
124+
}
125+
return res
126+
})
127+
h.AssertCounts(memsource.Counts{Acked: 3})
128+
}
129+
130+
// TestHarness_RunBatch_BatchedCapability drives the WithBatched subscription so
131+
// the engine takes the whole-batch NextBatch/SettleBatch path. It asserts the
132+
// batched inlet still delivers and settles every queued message in order.
133+
func TestHarness_RunBatch_BatchedCapability(t *testing.T) {
134+
t.Parallel()
135+
const count = 12
136+
msgs := make([]memsource.Msg, count)
137+
for i := range msgs {
138+
msgs[i] = memsource.Msg{Key: "k", Value: []byte(fmt.Sprintf("%d", i))}
139+
}
140+
141+
var (
142+
mu sync.Mutex
143+
delivered []string
144+
)
145+
h := memsource.NewHarnessWith(t,
146+
[]source.Option{source.WithBatch(8, 0)},
147+
[]memsource.Option{memsource.WithBatched()},
148+
msgs...,
149+
)
150+
h.RunBatch(func(_ context.Context, ms []source.Message) []source.Result {
151+
res := make([]source.Result, len(ms))
152+
mu.Lock()
153+
for i, m := range ms {
154+
delivered = append(delivered, string(m.Value()))
155+
res[i] = source.Ack()
156+
}
157+
mu.Unlock()
158+
return res
159+
})
160+
161+
want := make([]string, count)
162+
for i := range want {
163+
want[i] = fmt.Sprintf("%d", i)
164+
}
165+
if !equalStrings(delivered, want) {
166+
t.Fatalf("batched delivery = %v, want %v", delivered, want)
167+
}
168+
h.AssertCounts(memsource.Counts{Acked: count})
169+
h.AssertSettled(count)
170+
}
171+
172+
// TestSubscription_NextBatch_DrainsQueued exercises the batched subscription's
173+
// NextBatch directly: it blocks for the first message then drains whatever else
174+
// is queued without blocking, capped at the limit, and never exceeds the queue.
175+
func TestSubscription_NextBatch_DrainsQueued(t *testing.T) {
176+
t.Parallel()
177+
in := memsource.New(memsource.WithBatched())
178+
in.Queue(
179+
memsource.Msg{Key: "k", Value: []byte("a")},
180+
memsource.Msg{Key: "k", Value: []byte("b")},
181+
memsource.Msg{Key: "k", Value: []byte("c")},
182+
)
183+
sub, err := in.Subscribe(context.Background(), source.SubscribeConfig{})
184+
if err != nil {
185+
t.Fatalf("Subscribe err = %v", err)
186+
}
187+
t.Cleanup(func() { _ = sub.Close() })
188+
189+
batched, ok := sub.(source.Batched)
190+
if !ok {
191+
t.Fatalf("WithBatched subscription does not satisfy source.Batched")
192+
}
193+
194+
// limit below 1 is normalized to 1: a single message comes back.
195+
first, err := batched.NextBatch(context.Background(), 0)
196+
if err != nil {
197+
t.Fatalf("NextBatch(0) err = %v", err)
198+
}
199+
if len(first) != 1 || string(first[0].Value()) != "a" {
200+
t.Fatalf("NextBatch(0) = %v, want one message 'a'", values(first))
201+
}
202+
203+
// A larger limit drains the remaining two without blocking, capped at queue.
204+
rest, err := batched.NextBatch(context.Background(), 10)
205+
if err != nil {
206+
t.Fatalf("NextBatch(10) err = %v", err)
207+
}
208+
if got := values(rest); !equalStrings(got, []string{"b", "c"}) {
209+
t.Fatalf("NextBatch(10) = %v, want [b c]", got)
210+
}
211+
212+
// SettleBatch records every message in the slice on the ledger.
213+
all := append(append([]source.Message{}, first...), rest...)
214+
if err := batched.SettleBatch(context.Background(), all, source.Ack()); err != nil {
215+
t.Fatalf("SettleBatch err = %v", err)
216+
}
217+
if got := in.Ledger().Len(); got != 3 {
218+
t.Fatalf("ledger len after SettleBatch = %d, want 3", got)
219+
}
220+
if c := in.Ledger().Counts(); c != (memsource.Counts{Acked: 3}) {
221+
t.Fatalf("counts after SettleBatch = %+v, want Acked:3", c)
222+
}
223+
}
224+
225+
// TestSubscription_NextBatch_DrainedReportsErr confirms NextBatch surfaces
226+
// ErrDrained once the subscription is closed and its queue is empty, the signal
227+
// the batch run loop uses to exit cleanly.
228+
func TestSubscription_NextBatch_DrainedReportsErr(t *testing.T) {
229+
t.Parallel()
230+
in := memsource.New(memsource.WithBatched())
231+
sub, err := in.Subscribe(context.Background(), source.SubscribeConfig{})
232+
if err != nil {
233+
t.Fatalf("Subscribe err = %v", err)
234+
}
235+
batched := sub.(source.Batched)
236+
_ = sub.Close()
237+
238+
if _, err := batched.NextBatch(context.Background(), 4); err != source.ErrDrained {
239+
t.Fatalf("NextBatch after close err = %v, want ErrDrained", err)
240+
}
241+
}
242+
243+
// TestMessage_AccessorsAndAsEscapeHatch asserts the in-memory message exposes
244+
// its Key and Headers, and that the As escape hatch declines a target type it
245+
// does not recognize (the documented narrow contract: it matches only the
246+
// concrete **message).
247+
func TestMessage_AccessorsAndAsEscapeHatch(t *testing.T) {
248+
t.Parallel()
249+
h := memsource.NewHarness(t, nil, memsource.Msg{
250+
Key: "route-key",
251+
Value: []byte("payload"),
252+
Headers: source.Headers{{Key: "tenant", Value: "acme"}},
253+
})
254+
h.Run(func(_ context.Context, m source.Message) source.Result {
255+
if got := string(m.Key()); got != "route-key" {
256+
t.Errorf("Key() = %q, want route-key", got)
257+
}
258+
if got, ok := m.Headers().Get("tenant"); !ok || got != "acme" {
259+
t.Errorf("Headers.Get(tenant) = %q,%v, want acme,true", got, ok)
260+
}
261+
// As declines a target it does not recognize; the concrete **message
262+
// target is unexported, so an external caller cannot match it.
263+
var other *int
264+
if m.As(&other) {
265+
t.Error("As matched an unrelated target type")
266+
}
267+
return source.Ack()
268+
})
269+
h.AssertCounts(memsource.Counts{Acked: 1})
270+
}
271+
272+
// equalInts reports whether two int slices have identical contents in order.
273+
func equalInts(a, b []int) bool {
274+
if len(a) != len(b) {
275+
return false
276+
}
277+
for i := range a {
278+
if a[i] != b[i] {
279+
return false
280+
}
281+
}
282+
return true
283+
}
284+
285+
// equalStrings reports whether two string slices have identical contents in
286+
// order.
287+
func equalStrings(a, b []string) bool {
288+
if len(a) != len(b) {
289+
return false
290+
}
291+
for i := range a {
292+
if a[i] != b[i] {
293+
return false
294+
}
295+
}
296+
return true
297+
}
298+
299+
// values extracts the string values of a message slice for assertion messages.
300+
func values(ms []source.Message) []string {
301+
out := make([]string, len(ms))
302+
for i, m := range ms {
303+
out[i] = string(m.Value())
304+
}
305+
return out
306+
}

0 commit comments

Comments
 (0)