Skip to content

Commit dc32523

Browse files
committed
test: cover source batched path in kafka and jetstream adapters
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 6e7053d commit dc32523

2 files changed

Lines changed: 498 additions & 0 deletions

File tree

source/jetstream/batch_test.go

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package jetstream
4+
5+
import (
6+
"context"
7+
"errors"
8+
"testing"
9+
10+
njs "github.com/nats-io/nats.go/jetstream"
11+
12+
"github.com/stablekernel/crucible/source"
13+
)
14+
15+
// --- NextBatch: block-for-first then drain-buffered --------------------------
16+
17+
func TestNextBatch_ClampsLimitBelowOne(t *testing.T) {
18+
t.Parallel()
19+
// A non-positive limit clamps to 1, so NextBatch blocks for and returns
20+
// exactly the first message rather than an empty slice.
21+
js := newFakeJS(
22+
&fakeMsg{subject: "orders.placed", data: []byte("a"), seq: 1},
23+
&fakeMsg{subject: "orders.paid", data: []byte("b"), seq: 2},
24+
)
25+
sub := newSub(t, js)
26+
27+
bs, ok := sub.(source.Batched)
28+
if !ok {
29+
t.Fatal("subscription does not satisfy source.Batched")
30+
}
31+
msgs, err := bs.NextBatch(context.Background(), 0)
32+
if err != nil {
33+
t.Fatalf("NextBatch(0) error = %v", err)
34+
}
35+
if len(msgs) != 1 {
36+
t.Fatalf("NextBatch(0) = %d messages, want 1 (clamped)", len(msgs))
37+
}
38+
if string(msgs[0].Value()) != "a" {
39+
t.Errorf("first message = %q, want a", msgs[0].Value())
40+
}
41+
}
42+
43+
func TestNextBatch_DrainsBufferedUpToLimit(t *testing.T) {
44+
t.Parallel()
45+
// The pull consumer has prefetched several messages; NextBatch blocks for the
46+
// first then opportunistically drains the rest the iterator already holds,
47+
// returning them in arrival order.
48+
js := newFakeJS(
49+
&fakeMsg{subject: "orders.placed", data: []byte("a"), seq: 1},
50+
&fakeMsg{subject: "orders.paid", data: []byte("b"), seq: 2},
51+
&fakeMsg{subject: "orders.shipped", data: []byte("c"), seq: 3},
52+
)
53+
sub := newSub(t, js)
54+
55+
bs := sub.(source.Batched)
56+
msgs, err := bs.NextBatch(context.Background(), 10)
57+
if err != nil {
58+
t.Fatalf("NextBatch() error = %v", err)
59+
}
60+
if len(msgs) != 3 {
61+
t.Fatalf("NextBatch() = %d messages, want 3 drained", len(msgs))
62+
}
63+
want := []string{"a", "b", "c"}
64+
for i, m := range msgs {
65+
if string(m.Value()) != want[i] {
66+
t.Errorf("message[%d] = %q, want %q", i, m.Value(), want[i])
67+
}
68+
}
69+
}
70+
71+
func TestNextBatch_StopsAtLimit(t *testing.T) {
72+
t.Parallel()
73+
// More messages are buffered than requested: NextBatch returns exactly limit
74+
// and leaves the remainder for the next call.
75+
js := newFakeJS(
76+
&fakeMsg{subject: "s", data: []byte("a"), seq: 1},
77+
&fakeMsg{subject: "s", data: []byte("b"), seq: 2},
78+
&fakeMsg{subject: "s", data: []byte("c"), seq: 3},
79+
&fakeMsg{subject: "s", data: []byte("d"), seq: 4},
80+
)
81+
sub := newSub(t, js)
82+
bs := sub.(source.Batched)
83+
84+
first, err := bs.NextBatch(context.Background(), 2)
85+
if err != nil {
86+
t.Fatalf("first NextBatch() error = %v", err)
87+
}
88+
if len(first) != 2 {
89+
t.Fatalf("first NextBatch() = %d messages, want 2", len(first))
90+
}
91+
if string(first[0].Value()) != "a" || string(first[1].Value()) != "b" {
92+
t.Errorf("first batch = %q/%q, want a/b", first[0].Value(), first[1].Value())
93+
}
94+
95+
second, err := bs.NextBatch(context.Background(), 2)
96+
if err != nil {
97+
t.Fatalf("second NextBatch() error = %v", err)
98+
}
99+
if len(second) != 2 || string(second[0].Value()) != "c" || string(second[1].Value()) != "d" {
100+
t.Errorf("second batch = %v, want c/d", second)
101+
}
102+
}
103+
104+
func TestNextBatch_FirstMessageDrainPropagatesError(t *testing.T) {
105+
t.Parallel()
106+
// When the first blocking read fails to create the consumer, NextBatch
107+
// surfaces that error rather than returning a partial batch.
108+
js := &fakeJS{createErr: errors.New("boom")}
109+
sub := newSub(t, js)
110+
bs := sub.(source.Batched)
111+
112+
if _, err := bs.NextBatch(context.Background(), 4); err == nil || !errContains(err, "create consumer") {
113+
t.Fatalf("NextBatch() = %v, want create-consumer error", err)
114+
}
115+
}
116+
117+
func TestNextBatch_DrainBreaksAfterClose(t *testing.T) {
118+
t.Parallel()
119+
// A single buffered message is returned; the drain loop then sees the closed
120+
// subscription and returns the one message it has rather than blocking.
121+
js := newFakeJS(&fakeMsg{subject: "s", data: []byte("only"), seq: 1})
122+
sub := newSub(t, js)
123+
bs := sub.(source.Batched)
124+
125+
msgs, err := bs.NextBatch(context.Background(), 8)
126+
if err != nil {
127+
t.Fatalf("NextBatch() error = %v", err)
128+
}
129+
// The fake iterator yields one message then reports closed, so the drain
130+
// loop breaks and the batch holds exactly that message.
131+
if len(msgs) != 1 || string(msgs[0].Value()) != "only" {
132+
t.Fatalf("NextBatch() = %v, want a single message", msgs)
133+
}
134+
}
135+
136+
func TestNextBatch_DrainedReturnsErrDrained(t *testing.T) {
137+
t.Parallel()
138+
// A closed subscription has no first message to block for, so NextBatch maps
139+
// the drain exactly as Next does.
140+
sub := newSub(t, newFakeJS(&fakeMsg{subject: "s", data: []byte("a")}))
141+
if err := sub.Close(); err != nil {
142+
t.Fatalf("Close() error = %v", err)
143+
}
144+
bs := sub.(source.Batched)
145+
if _, err := bs.NextBatch(context.Background(), 4); !errors.Is(err, source.ErrDrained) {
146+
t.Errorf("NextBatch() after close = %v, want ErrDrained", err)
147+
}
148+
}
149+
150+
// --- SettleBatch: per-message ack vocabulary ---------------------------------
151+
152+
func TestSettleBatch_AcksEveryMessage(t *testing.T) {
153+
t.Parallel()
154+
// One Ack result applied to a batch double-acks every message.
155+
fms := []*fakeMsg{
156+
{subject: "s", data: []byte("a"), seq: 1},
157+
{subject: "s", data: []byte("b"), seq: 2},
158+
{subject: "s", data: []byte("c"), seq: 3},
159+
}
160+
msgs := make([]njs.Msg, len(fms))
161+
for i, fm := range fms {
162+
msgs[i] = fm
163+
}
164+
js := newFakeJS(msgs...)
165+
sub := newSub(t, js)
166+
bs := sub.(source.Batched)
167+
168+
batch, err := bs.NextBatch(context.Background(), 3)
169+
if err != nil {
170+
t.Fatalf("NextBatch() error = %v", err)
171+
}
172+
if len(batch) != 3 {
173+
t.Fatalf("NextBatch() = %d, want 3", len(batch))
174+
}
175+
if err := bs.SettleBatch(context.Background(), batch, source.Ack()); err != nil {
176+
t.Fatalf("SettleBatch() error = %v", err)
177+
}
178+
for i, fm := range fms {
179+
if !fm.doubleAck {
180+
t.Errorf("message[%d] doubleAck = false, want true", i)
181+
}
182+
}
183+
}
184+
185+
func TestSettleBatch_ReturnsFirstErrorButSettlesAll(t *testing.T) {
186+
t.Parallel()
187+
// Every ack fails; SettleBatch returns the first wrapped error yet still
188+
// attempts every message in the batch.
189+
boom := errors.New("server unreachable")
190+
fms := []*fakeMsg{
191+
{subject: "s", data: []byte("a"), seq: 1, settleErr: boom},
192+
{subject: "s", data: []byte("b"), seq: 2, settleErr: boom},
193+
}
194+
msgs := []njs.Msg{fms[0], fms[1]}
195+
sub := newSub(t, newFakeJS(msgs...))
196+
bs := sub.(source.Batched)
197+
198+
batch, err := bs.NextBatch(context.Background(), 2)
199+
if err != nil {
200+
t.Fatalf("NextBatch() error = %v", err)
201+
}
202+
err = bs.SettleBatch(context.Background(), batch, source.Ack())
203+
if !errors.Is(err, boom) {
204+
t.Fatalf("SettleBatch() = %v, want wrapped %v", err, boom)
205+
}
206+
for i, fm := range fms {
207+
if !fm.acked {
208+
t.Errorf("message[%d] not attempted, want every message settled", i)
209+
}
210+
}
211+
}
212+
213+
func TestSettleBatch_EmptyIsNoError(t *testing.T) {
214+
t.Parallel()
215+
sub := newSub(t, newFakeJS())
216+
bs := sub.(source.Batched)
217+
if err := bs.SettleBatch(context.Background(), nil, source.Ack()); err != nil {
218+
t.Errorf("SettleBatch(nil) = %v, want nil", err)
219+
}
220+
}

0 commit comments

Comments
 (0)