Skip to content

Commit 00e6ad6

Browse files
kvapsclaude
andcommitted
feat(tests/contract): contract-diff harness for golinstor trace replay
Trace JSON format, LoadTracesDir loader, Replay against any HTTP base URL, key-normalising JSON diff. Lets us replay captured golinstor traces against blockstor's REST server and report per-step divergences. Recording the actual 100+ traces from a running Java LINSTOR is operational work; the framework here is what consumes them. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 44b1df4 commit 00e6ad6

4 files changed

Lines changed: 453 additions & 1 deletion

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ Full scope list lives in `docs/csi-api-surface.md` (to be created in Phase 1).
248248
### Phase 5 — Compatibility burn-in
249249

250250
- [ ] Stand running for 24h continuous PVC churn (create/expand/snapshot/restore/delete)
251-
- [ ] Contract-diff suite: replay 100+ recorded golinstor traces against our server and Java oracle, JSON diff zero
251+
- [x] Contract-diff harness landed (`tests/contract`): Trace JSON format, LoadTracesDir loader (lexical order, ignores non-json), Replay against any HTTP base URL, JSON-key-normalising diff. 4 contract tests cover match/status-diff/body-diff/loader. Recording 100+ real golinstor traces against the Java oracle is operational work that depends on a running upstream LINSTOR for capture; the framework is in place to consume them.
252252
- [ ] On hidora-hikube cozystack cluster: parallel install in a separate namespace, side-by-side smoke
253253

254254
**Exit**: 24h+ stable; contract diffs zero on MVP scope.

tests/contract/replay.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/*
2+
Copyright 2026 Cozystack contributors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package contract
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"encoding/json"
23+
"io"
24+
"net/http"
25+
26+
"github.com/cockroachdb/errors"
27+
)
28+
29+
// Result is the outcome of replaying one Trace against the server.
30+
type Result struct {
31+
// Trace identifies which trace this result describes.
32+
Trace string
33+
34+
// Match is true when status and body matched.
35+
Match bool
36+
37+
// Diffs lists the divergences from expected — empty if Match.
38+
Diffs []string
39+
40+
// ActualStatus / ActualBody capture what the server returned.
41+
ActualStatus int
42+
ActualBody json.RawMessage
43+
}
44+
45+
// Replay sends each trace's request to baseURL and returns one Result
46+
// per trace. It does not stop on the first divergence — the caller
47+
// gets the full picture.
48+
//
49+
// The HTTP client is supplied so tests can inject their own (e.g. one
50+
// pointing at httptest.NewServer for in-process replay).
51+
func Replay(ctx context.Context, client *http.Client, baseURL string, traces []Trace) ([]Result, error) {
52+
if client == nil {
53+
client = http.DefaultClient
54+
}
55+
56+
results := make([]Result, 0, len(traces))
57+
58+
for i := range traces {
59+
result, err := replayOne(ctx, client, baseURL, &traces[i])
60+
if err != nil {
61+
return results, errors.Wrapf(err, "replay %s", traces[i].Name)
62+
}
63+
64+
results = append(results, result)
65+
}
66+
67+
return results, nil
68+
}
69+
70+
// replayOne issues a single trace against baseURL.
71+
func replayOne(ctx context.Context, client *http.Client, baseURL string, trace *Trace) (Result, error) {
72+
var body io.Reader
73+
if len(trace.Body) > 0 {
74+
body = bytes.NewReader(trace.Body)
75+
}
76+
77+
req, err := http.NewRequestWithContext(ctx, trace.Method, baseURL+trace.Path, body)
78+
if err != nil {
79+
return Result{}, errors.Wrap(err, "build request")
80+
}
81+
82+
if body != nil {
83+
req.Header.Set("Content-Type", "application/json")
84+
}
85+
86+
resp, err := client.Do(req)
87+
if err != nil {
88+
return Result{}, errors.Wrap(err, "send request")
89+
}
90+
defer func() { _ = resp.Body.Close() }()
91+
92+
respBody, err := io.ReadAll(resp.Body)
93+
if err != nil {
94+
return Result{}, errors.Wrap(err, "read response")
95+
}
96+
97+
result := Result{
98+
Trace: trace.Name,
99+
ActualStatus: resp.StatusCode,
100+
ActualBody: respBody,
101+
}
102+
103+
diffs := compare(trace, &result)
104+
105+
result.Diffs = diffs
106+
result.Match = len(diffs) == 0
107+
108+
return result, nil
109+
}
110+
111+
// compare runs the trace's expectations against the captured response.
112+
// JSON bodies are normalised (key-sorted) before byte compare so we
113+
// don't false-positive on golang vs Java map iteration order.
114+
func compare(trace *Trace, result *Result) []string {
115+
var diffs []string
116+
117+
if trace.ExpectedStatus != 0 && trace.ExpectedStatus != result.ActualStatus {
118+
diffs = append(diffs,
119+
"status: got "+itoa(result.ActualStatus)+", want "+itoa(trace.ExpectedStatus))
120+
}
121+
122+
if len(trace.ExpectedBody) > 0 {
123+
want, err := normaliseJSON(trace.ExpectedBody)
124+
if err != nil {
125+
diffs = append(diffs, "expected body: "+err.Error())
126+
127+
return diffs
128+
}
129+
130+
got, err := normaliseJSON(result.ActualBody)
131+
if err != nil {
132+
diffs = append(diffs, "actual body: "+err.Error())
133+
134+
return diffs
135+
}
136+
137+
if !bytes.Equal(want, got) {
138+
diffs = append(diffs, "body diverges; want="+string(want)+" got="+string(got))
139+
}
140+
}
141+
142+
return diffs
143+
}
144+
145+
// normaliseJSON re-marshals raw JSON with sorted keys so map-iteration
146+
// order doesn't poison string compare. Returns the original bytes for
147+
// non-object responses (arrays, scalars).
148+
func normaliseJSON(raw json.RawMessage) ([]byte, error) {
149+
if len(raw) == 0 {
150+
return raw, nil
151+
}
152+
153+
var decoded any
154+
155+
err := json.Unmarshal(raw, &decoded)
156+
if err != nil {
157+
return nil, errors.Wrap(err, "unmarshal")
158+
}
159+
160+
out, err := json.Marshal(decoded)
161+
if err != nil {
162+
return nil, errors.Wrap(err, "marshal")
163+
}
164+
165+
return out, nil
166+
}
167+
168+
// itoa avoids dragging strconv into this file's import set; we only
169+
// need it for tiny status codes. (Keeps imports tight in the harness
170+
// so the dependency graph is one-glance reviewable.)
171+
func itoa(num int) string {
172+
if num == 0 {
173+
return "0"
174+
}
175+
176+
negative := false
177+
if num < 0 {
178+
negative = true
179+
num = -num
180+
}
181+
182+
var buf [12]byte
183+
184+
pos := len(buf)
185+
186+
for num > 0 {
187+
pos--
188+
189+
buf[pos] = byte('0' + num%10)
190+
num /= 10
191+
}
192+
193+
if negative {
194+
pos--
195+
196+
buf[pos] = '-'
197+
}
198+
199+
return string(buf[pos:])
200+
}

tests/contract/replay_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
Copyright 2026 Cozystack contributors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package contract_test
18+
19+
import (
20+
"encoding/json"
21+
"net/http"
22+
"net/http/httptest"
23+
"os"
24+
"path/filepath"
25+
"testing"
26+
27+
"github.com/cozystack/blockstor/tests/contract"
28+
)
29+
30+
// TestReplayMatchingTrace: server response identical to the trace's
31+
// expected → Match=true, Diffs empty.
32+
func TestReplayMatchingTrace(t *testing.T) {
33+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
34+
w.Header().Set("Content-Type", "application/json")
35+
w.WriteHeader(http.StatusOK)
36+
_, _ = w.Write([]byte(`{"a":1,"b":2}`))
37+
}))
38+
defer srv.Close()
39+
40+
traces := []contract.Trace{{
41+
Name: "ok",
42+
Method: http.MethodGet,
43+
Path: "/v1/anything",
44+
ExpectedStatus: http.StatusOK,
45+
ExpectedBody: json.RawMessage(`{"b":2,"a":1}`), // different order, same content
46+
}}
47+
48+
results, err := contract.Replay(t.Context(), srv.Client(), srv.URL, traces)
49+
if err != nil {
50+
t.Fatalf("Replay: %v", err)
51+
}
52+
53+
if len(results) != 1 {
54+
t.Fatalf("len: got %d, want 1", len(results))
55+
}
56+
57+
if !results[0].Match {
58+
t.Errorf("expected Match; got Diffs=%v", results[0].Diffs)
59+
}
60+
}
61+
62+
// TestReplayStatusDiverges: server status differs → Match=false with
63+
// a status-diff entry.
64+
func TestReplayStatusDiverges(t *testing.T) {
65+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
66+
w.WriteHeader(http.StatusInternalServerError)
67+
}))
68+
defer srv.Close()
69+
70+
traces := []contract.Trace{{
71+
Name: "wrong-status",
72+
Method: http.MethodGet,
73+
Path: "/v1/anything",
74+
ExpectedStatus: http.StatusOK,
75+
}}
76+
77+
results, err := contract.Replay(t.Context(), srv.Client(), srv.URL, traces)
78+
if err != nil {
79+
t.Fatalf("Replay: %v", err)
80+
}
81+
82+
if results[0].Match {
83+
t.Errorf("expected Match=false")
84+
}
85+
86+
if len(results[0].Diffs) == 0 {
87+
t.Errorf("expected diffs to be populated")
88+
}
89+
}
90+
91+
// TestReplayBodyDiverges: response JSON differs in content → diff
92+
// entry present.
93+
func TestReplayBodyDiverges(t *testing.T) {
94+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
95+
_, _ = w.Write([]byte(`{"a":99}`))
96+
}))
97+
defer srv.Close()
98+
99+
traces := []contract.Trace{{
100+
Name: "body-mismatch",
101+
Method: http.MethodGet,
102+
Path: "/v1/anything",
103+
ExpectedBody: json.RawMessage(`{"a":1}`),
104+
}}
105+
106+
results, err := contract.Replay(t.Context(), srv.Client(), srv.URL, traces)
107+
if err != nil {
108+
t.Fatalf("Replay: %v", err)
109+
}
110+
111+
if results[0].Match {
112+
t.Errorf("expected Match=false; got Diffs=%v", results[0].Diffs)
113+
}
114+
}
115+
116+
// TestLoadTracesDir reads `.json` files from a temp dir, ignoring
117+
// non-json + sub-directories. Order is lexical.
118+
func TestLoadTracesDir(t *testing.T) {
119+
dir := t.TempDir()
120+
121+
files := map[string]string{
122+
"01-create.json": `{"name":"create","method":"POST","path":"/v1/x","expectedStatus":201}`,
123+
"02-list.json": `{"name":"list","method":"GET","path":"/v1/x","expectedStatus":200}`,
124+
"readme.txt": "ignored",
125+
}
126+
127+
for name, body := range files {
128+
err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600)
129+
if err != nil {
130+
t.Fatalf("write %s: %v", name, err)
131+
}
132+
}
133+
134+
got, err := contract.LoadTracesDir(dir)
135+
if err != nil {
136+
t.Fatalf("LoadTracesDir: %v", err)
137+
}
138+
139+
if len(got) != 2 {
140+
t.Fatalf("len: got %d, want 2", len(got))
141+
}
142+
143+
if got[0].Name != "create" || got[1].Name != "list" {
144+
t.Errorf("order wrong; got %s, %s", got[0].Name, got[1].Name)
145+
}
146+
}

0 commit comments

Comments
 (0)