Files
mesh-delivery/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/open_test.go
T
jschoubben ca667af562
mesh/repo-check head ca667af562b0 merged into trunk 1325c8b452c8 beside mesh-controller#400@a0c971ce — pass: its merge-check.sh passed
mesh/merge-gate head ca667af562b0 merged into trunk 1325c8b452c8 — pass: group fix/1279-consumers-from-now composed: every machine composes with the change…
mesh/delivery delivered
mesh/delivery-group group fix/1279-consumers-from-now delivered: every member is delivered
Keep the head last written per pull request in a bucket of the bridge's own instead of scanning FORGE, and test the bridge under its composed permissions (issue 1279, ADR 0443)
2026-10-19 22:09:41 +02:00

293 lines
11 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// openAnswer is gitea_open_pulls's answer as mesh-catalog modules/gitea/client.ts:369-381 builds its entries inside {pulls}.
func openAnswer() json.RawMessage {
return json.RawMessage(`{"pulls":[
{"owner":"novox","repo":"mesh-delivery","number":12,"base":"main","head":"feat/x","head_sha":"` + head + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]},
{"owner":"novox","repo":"mesh-catalog","number":300,"base":"main","head":"feat/x","head_sha":"` + strings.Repeat("a", 40) + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]},
{"owner":"novox","repo":"mesh-catalog","number":301,"base":"feat/y","head":"feat/z","head_sha":"` + strings.Repeat("b", 40) + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]}]}`)
}
func TestSyncOpenWritesOneUpdatedMessagePerOpenPull(t *testing.T) { // 1237/AC1, AC4
var logs bytes.Buffer
pub := &fakePub{}
var keys []string
call := func(_ context.Context, key string, _ any) (json.RawMessage, error) {
keys = append(keys, key)
return openAnswer(), nil
}
if err := runSync(call, pub, newHeads(), &sync.Mutex{}, log.New(&logs, "", 0)); err != nil {
t.Fatal(err)
}
if len(keys) != 1 || keys[0] != "gitea.gitea_open_pulls" {
t.Errorf("calls %v", keys)
}
if len(pub.subjects) != 3 {
t.Fatalf("%d messages written (%v), want two pull messages and the open message: the pull request into feat/y gets none", len(pub.subjects), pub.subjects)
}
want := []string{"mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.pull", "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-catalog.pull"}
for i, w := range want {
f, err := delivery.DecodeForge(pub.data[i])
if pub.subjects[i] != w || err != nil || f.Action != "updated" || f.DirsKnown || f.Branch != "feat/x" {
t.Errorf("message %d on %s = %+v err %v", i, pub.subjects[i], f, err)
}
}
}
// memHeads is the bucket heads in memory.
type memHeads struct {
mu sync.Mutex
vals map[string]string
getErr error
}
func newHeads(kv ...string) *memHeads {
m := &memHeads{vals: map[string]string{}}
for i := 0; i+1 < len(kv); i += 2 {
m.vals[kv[i]] = kv[i+1]
}
return m
}
func (m *memHeads) Get(_ context.Context, key string) (string, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.vals[key]
return v, ok, m.getErr
}
func (m *memHeads) Put(_ context.Context, key, head string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.vals[key] = head
return nil
}
func (m *memHeads) Delete(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.vals, key)
return nil
}
func (m *memHeads) get(key string) string { m.mu.Lock(); defer m.mu.Unlock(); return m.vals[key] }
// runSync runs syncOpen with the recorder the program writes through.
func runSync(call caller, pub publisher, heads headStore, mu *sync.Mutex, logger *log.Logger) error {
return syncOpen(context.Background(), call, &recorder{pub: pub, heads: heads, logger: logger}, heads, mu, logger)
}
// 1279/AC6: the sync writes a pull message only for a head that differs from what FORGE holds, then one open message
// naming every open pull request into trunk it read, those whose head did not differ among them.
func TestSyncOpenWritesPullsForNewHeadsThenOneOpenMessageNamingAll(t *testing.T) { // 1279/AC6
pub := &fakePub{}
call := func(context.Context, string, any) (json.RawMessage, error) { return openAnswer(), nil }
heads := newHeads("novox.mesh-delivery.12", head)
var logs bytes.Buffer
if err := runSync(call, pub, heads, &sync.Mutex{}, log.New(&logs, "", 0)); err != nil {
t.Fatal(err)
}
if heads.get("novox.mesh-catalog.300") != strings.Repeat("a", 40) || heads.get("novox.mesh-delivery.12") != head {
t.Errorf("the bucket after the sync: %v, want the head written for mesh-catalog#300 kept beside the held one", heads.vals)
}
if len(pub.subjects) != 2 {
t.Fatalf("%d messages written (%v), want the one pull message that differs and the open message", len(pub.subjects), pub.subjects)
}
if pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-catalog.pull" {
t.Errorf("first message on %s, want mesh-catalog's pull message", pub.subjects[0])
}
if pub.subjects[1] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.open" {
t.Fatalf("last message on %s, want the open message after the pull messages", pub.subjects[1])
}
f, err := delivery.DecodeForge(pub.data[1])
want := []delivery.OpenPull{{Repository: "novox/mesh-catalog", Number: 300}, {Repository: "novox/mesh-delivery", Number: 12}}
if err != nil || len(f.Open) != 2 || f.Open[0] != want[0] || f.Open[1] != want[1] {
t.Errorf("open message = %+v err %v, want it to name %v, the pull request whose head did not differ among them", f, err, want)
}
}
// The bucket cannot be read: every open pull request is written, as before, and the open message follows; the
// indexer's writes are idempotent, so the fault costs messages and no state.
func TestSyncOpenWritesAllWhenTheHeadsCannotBeRead(t *testing.T) { // 1279/AC6
pub := &fakePub{}
call := func(context.Context, string, any) (json.RawMessage, error) { return openAnswer(), nil }
heads := newHeads("novox.mesh-delivery.12", head)
heads.getErr = errors.New("the bucket is gone")
var logs bytes.Buffer
if err := runSync(call, pub, heads, &sync.Mutex{}, log.New(&logs, "", 0)); err != nil {
t.Fatal(err)
}
if len(pub.subjects) != 3 || !strings.Contains(logs.String(), "the bucket is gone") {
t.Errorf("messages %v, log %q", pub.subjects, logs.String())
}
}
// What the bridge writes on FORGE it keeps in the bucket: a head by its pull message, nothing by a closed or merged one.
func TestWhatIsWrittenOnFORGEIsKeptInTheBucket(t *testing.T) { // 1279/AC6
pub := &fakePub{}
heads := newHeads()
r := &recorder{pub: pub, heads: heads, logger: log.New(&bytes.Buffer{}, "", 0)}
write := func(f delivery.Forge) {
t.Helper()
ev, _ := delivery.ForgeEvent(f.Repository, f.Kind)
raw, err := delivery.EncodeForge(f)
if err != nil {
t.Fatal(err)
}
if _, err := r.Publish(context.Background(), forgeSubject(ev), raw); err != nil {
t.Fatal(err)
}
}
p := delivery.Forge{Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: delivery.PullUpdated}
write(p)
if heads.get("novox.mesh-delivery.12") != head {
t.Fatalf("bucket %v", heads.vals)
}
p.Action = delivery.PullClosed
write(p)
if _, kept := heads.vals["novox.mesh-delivery.12"]; kept {
t.Errorf("a closed pull request is still kept: %v", heads.vals)
}
p.Action = delivery.PullUpdated
write(p)
write(delivery.Forge{Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Merge: "m", Root: "r", Trees: map[string]string{"": "t"}})
if _, kept := heads.vals["novox.mesh-delivery.12"]; kept {
t.Errorf("a merged pull request is still kept: %v", heads.vals)
}
if len(pub.subjects) != 4 {
t.Errorf("%d messages written", len(pub.subjects))
}
}
// 1279/AC6: no forge event is heard between reading the forge and writing the open message.
func TestNoForgeEventIsHeardWhileTheOpenPullsAreSaid(t *testing.T) { // 1279/AC6
pub := &lockedPub{}
reading := make(chan struct{})
release := make(chan struct{})
call := func(context.Context, string, any) (json.RawMessage, error) {
close(reading)
<-release
return openAnswer(), nil
}
var mu sync.Mutex
synced := make(chan error, 1)
go func() {
synced <- runSync(call, pub, newHeads(), &mu, log.New(&bytes.Buffer{}, "", 0))
}()
<-reading
// An event arrives while the forge is being read.
heard := make(chan struct{})
go func() {
hear(&mu, func() { _, _ = pub.Publish(context.Background(), "event", nil); close(heard) })
}()
select {
case <-heard:
t.Fatal("an event was heard while the forge was read")
case <-time.After(100 * time.Millisecond):
}
close(release)
if err := <-synced; err != nil {
t.Fatal(err)
}
<-heard
got := pub.snapshot()
if len(got) != 4 || got[2] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.open" || got[3] != "event" {
t.Errorf("order of writes: %v, want the event after the open message", got)
}
}
type lockedPub struct {
mu sync.Mutex
subjects []string
}
func (p *lockedPub) Publish(_ context.Context, subject string, _ []byte, _ ...jetstream.PublishOpt) (*jetstream.PubAck, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.subjects = append(p.subjects, subject)
return &jetstream.PubAck{}, nil
}
func (p *lockedPub) snapshot() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.subjects...)
}
func TestSyncOpenFailureNamesTheCallAndTheForgesWords(t *testing.T) { // 1237/AC3
pub := &fakePub{}
call := func(context.Context, string, any) (json.RawMessage, error) {
return nil, errors.New("the forge said 502")
}
err := runSync(call, pub, newHeads(), &sync.Mutex{}, log.New(&bytes.Buffer{}, "", 0))
if err == nil || !strings.Contains(err.Error(), "gitea.gitea_open_pulls") || !strings.Contains(err.Error(), "the forge said 502") {
t.Errorf("err = %v", err)
}
if len(pub.subjects) != 0 {
t.Errorf("messages written: %v", pub.subjects)
}
}
func TestReconnectReadsTheOpenPullsAgainAndFailureGoesOn(t *testing.T) { // 1237/AC2, AC3
var logs lockedBuffer
ctx, cancel := context.WithCancel(context.Background())
again := make(chan struct{}, 1)
done := make(chan struct{})
var mu sync.Mutex
n := 0
count := func() int { mu.Lock(); defer mu.Unlock(); return n }
read := func(context.Context) error {
mu.Lock()
defer mu.Unlock()
n++
if n == 1 {
return errors.New("the forge said 502")
}
return nil
}
go func() { onReconnect(ctx, again, read, log.New(&logs, "", 0)); close(done) }()
again <- struct{}{}
waitFor(t, func() bool { return count() >= 1 })
again <- struct{}{} // the first read failed; the bridge goes on and reads again at the next reconnect
waitFor(t, func() bool { return count() >= 2 })
cancel()
<-done
if !strings.Contains(logs.String(), "the forge said 502") {
t.Errorf("log %q does not carry the forge's words", logs.String())
}
}
type lockedBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (l *lockedBuffer) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.Write(p)
}
func (l *lockedBuffer) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.b.String() }
func waitFor(t *testing.T, ok func() bool) {
t.Helper()
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
if ok() {
return
}
}
t.Fatal("it did not happen within 5 s")
}