Skip to content

Commit e1b8ae4

Browse files
committed
test: add unit tests for failover, fanout, and renderer
Failover: first-peer, skip-bad-peer, all-bad-peers. Fan-out: subscribe-broadcast, unsubscribe, broadcast-to-none. Renderer: creates files with origin tags, appends to existing, filename generation. Total hub test count: 26. Signed-off-by: Murat Parlakisik <parlakisik@gmail.com>
1 parent 8b03202 commit e1b8ae4

4 files changed

Lines changed: 346 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// / ctx: https://ctx.ist
2+
// ,'`./ do you remember?
3+
// `.,'\
4+
// \ Copyright 2026-present Context contributors.
5+
// SPDX-License-Identifier: Apache-2.0
6+
7+
package render
8+
9+
import (
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
"testing"
14+
15+
"github.com/ActiveMemory/ctx/internal/hub"
16+
"github.com/ActiveMemory/ctx/internal/rc"
17+
)
18+
19+
func TestWriteEntries_CreatesFiles(t *testing.T) {
20+
tmpDir := t.TempDir()
21+
ctxDir := filepath.Join(tmpDir, ".context")
22+
if mkErr := os.MkdirAll(ctxDir, 0750); mkErr != nil {
23+
t.Fatal(mkErr)
24+
}
25+
26+
origDir, _ := os.Getwd()
27+
if chErr := os.Chdir(tmpDir); chErr != nil {
28+
t.Fatal(chErr)
29+
}
30+
defer func() { _ = os.Chdir(origDir) }()
31+
rc.Reset()
32+
33+
entries := []hub.EntryMsg{
34+
{
35+
Type: "decision",
36+
Content: "Use UTC timestamps",
37+
Origin: "alpha",
38+
Timestamp: 1710422400,
39+
Sequence: 1,
40+
},
41+
{
42+
Type: "learning",
43+
Content: "Avoid mocks in integration tests",
44+
Origin: "beta",
45+
Timestamp: 1710422401,
46+
Sequence: 2,
47+
},
48+
}
49+
50+
if writeErr := WriteEntries(entries); writeErr != nil {
51+
t.Fatalf("WriteEntries: %v", writeErr)
52+
}
53+
54+
// Check decisions file.
55+
decPath := filepath.Join(
56+
ctxDir, "shared", "decisions.md",
57+
)
58+
decData, readErr := os.ReadFile(decPath)
59+
if readErr != nil {
60+
t.Fatalf("read decisions: %v", readErr)
61+
}
62+
decStr := string(decData)
63+
if !strings.Contains(decStr, "Use UTC timestamps") {
64+
t.Error("decisions.md missing content")
65+
}
66+
if !strings.Contains(decStr, "**Origin**: alpha") {
67+
t.Error("decisions.md missing origin tag")
68+
}
69+
70+
// Check learnings file.
71+
learnPath := filepath.Join(
72+
ctxDir, "shared", "learnings.md",
73+
)
74+
learnData, learnErr := os.ReadFile(learnPath)
75+
if learnErr != nil {
76+
t.Fatalf("read learnings: %v", learnErr)
77+
}
78+
if !strings.Contains(
79+
string(learnData), "Avoid mocks",
80+
) {
81+
t.Error("learnings.md missing content")
82+
}
83+
}
84+
85+
func TestWriteEntries_AppendsToExisting(t *testing.T) {
86+
tmpDir := t.TempDir()
87+
ctxDir := filepath.Join(tmpDir, ".context")
88+
sharedDir := filepath.Join(ctxDir, "shared")
89+
if mkErr := os.MkdirAll(sharedDir, 0750); mkErr != nil {
90+
t.Fatal(mkErr)
91+
}
92+
93+
origDir, _ := os.Getwd()
94+
if chErr := os.Chdir(tmpDir); chErr != nil {
95+
t.Fatal(chErr)
96+
}
97+
defer func() { _ = os.Chdir(origDir) }()
98+
rc.Reset()
99+
100+
// Pre-populate a file.
101+
existing := "## Existing content\n\n"
102+
decPath := filepath.Join(sharedDir, "decisions.md")
103+
if writeErr := os.WriteFile(
104+
decPath, []byte(existing), 0644,
105+
); writeErr != nil {
106+
t.Fatal(writeErr)
107+
}
108+
109+
entries := []hub.EntryMsg{
110+
{
111+
Type: "decision",
112+
Content: "New decision",
113+
Origin: "proj",
114+
Timestamp: 1710422400,
115+
Sequence: 1,
116+
},
117+
}
118+
119+
if writeErr := WriteEntries(entries); writeErr != nil {
120+
t.Fatal(writeErr)
121+
}
122+
123+
data, _ := os.ReadFile(decPath)
124+
content := string(data)
125+
if !strings.Contains(content, "Existing content") {
126+
t.Error("existing content was overwritten")
127+
}
128+
if !strings.Contains(content, "New decision") {
129+
t.Error("new entry was not appended")
130+
}
131+
}
132+
133+
func TestTypedFileName(t *testing.T) {
134+
tests := []struct {
135+
entryType string
136+
want string
137+
}{
138+
{"decision", "decisions.md"},
139+
{"learning", "learnings.md"},
140+
{"convention", "conventions.md"},
141+
}
142+
for _, tt := range tests {
143+
got := typedFileName(tt.entryType)
144+
if got != tt.want {
145+
t.Errorf(
146+
"typedFileName(%q) = %q, want %q",
147+
tt.entryType, got, tt.want,
148+
)
149+
}
150+
}
151+
}

internal/hub/failover_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// / ctx: https://ctx.ist
2+
// ,'`./ do you remember?
3+
// `.,'\
4+
// \ Copyright 2026-present Context contributors.
5+
// SPDX-License-Identifier: Apache-2.0
6+
7+
package hub
8+
9+
import (
10+
"testing"
11+
)
12+
13+
// TestFailoverClient_FirstPeerWorks verifies that the
14+
// failover client connects to the first reachable peer.
15+
func TestFailoverClient_FirstPeerWorks(t *testing.T) {
16+
_, _, adminTok := startTestServer(t)
17+
18+
// Start a second server.
19+
dir := t.TempDir()
20+
store, storeErr := NewStore(dir)
21+
if storeErr != nil {
22+
t.Fatal(storeErr)
23+
}
24+
srv := NewServer(store, adminTok)
25+
lis := listenRandom(t)
26+
go func() { _ = srv.Serve(lis) }()
27+
t.Cleanup(func() { srv.GracefulStop() })
28+
29+
addr := lis.Addr().String()
30+
31+
// Register a client on the second server.
32+
regClient, dialErr := NewClient(addr, "")
33+
if dialErr != nil {
34+
t.Fatal(dialErr)
35+
}
36+
resp, regErr := regClient.Register(
37+
testCtx(), adminTok, "failover-proj",
38+
)
39+
if regErr != nil {
40+
t.Fatal(regErr)
41+
}
42+
_ = regClient.Close()
43+
44+
// Failover client with the reachable peer first.
45+
client, foErr := NewFailoverClient(
46+
[]string{addr}, resp.ClientToken,
47+
)
48+
if foErr != nil {
49+
t.Fatalf("NewFailoverClient: %v", foErr)
50+
}
51+
defer func() { _ = client.Close() }()
52+
53+
status, statusErr := client.Status(testCtx())
54+
if statusErr != nil {
55+
t.Fatalf("Status: %v", statusErr)
56+
}
57+
if status.TotalEntries != 0 {
58+
t.Errorf("want 0 entries, got %d",
59+
status.TotalEntries)
60+
}
61+
}
62+
63+
// TestFailoverClient_SkipsBadPeer verifies that unreachable
64+
// peers are skipped.
65+
func TestFailoverClient_SkipsBadPeer(t *testing.T) {
66+
_, _, adminTok := startTestServer(t)
67+
68+
dir := t.TempDir()
69+
store, _ := NewStore(dir)
70+
srv := NewServer(store, adminTok)
71+
lis := listenRandom(t)
72+
go func() { _ = srv.Serve(lis) }()
73+
t.Cleanup(func() { srv.GracefulStop() })
74+
75+
addr := lis.Addr().String()
76+
77+
regClient, _ := NewClient(addr, "")
78+
resp, _ := regClient.Register(
79+
testCtx(), adminTok, "skip-proj",
80+
)
81+
_ = regClient.Close()
82+
83+
// First peer is unreachable, second is good.
84+
client, foErr := NewFailoverClient(
85+
[]string{"127.0.0.1:1", addr},
86+
resp.ClientToken,
87+
)
88+
if foErr != nil {
89+
t.Fatalf("expected fallback to work: %v", foErr)
90+
}
91+
_ = client.Close()
92+
}
93+
94+
// TestFailoverClient_AllBad verifies error when no peer is
95+
// reachable.
96+
func TestFailoverClient_AllBad(t *testing.T) {
97+
_, foErr := NewFailoverClient(
98+
[]string{"127.0.0.1:1", "127.0.0.1:2"},
99+
"bad-token",
100+
)
101+
if foErr == nil {
102+
t.Fatal("expected error when all peers bad")
103+
}
104+
}

internal/hub/fanout_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// / ctx: https://ctx.ist
2+
// ,'`./ do you remember?
3+
// `.,'\
4+
// \ Copyright 2026-present Context contributors.
5+
// SPDX-License-Identifier: Apache-2.0
6+
7+
package hub
8+
9+
import (
10+
"testing"
11+
"time"
12+
)
13+
14+
func TestFanOut_SubscribeAndBroadcast(t *testing.T) {
15+
fo := newFanOut()
16+
17+
ch1 := fo.subscribe()
18+
ch2 := fo.subscribe()
19+
20+
if fo.count() != 2 {
21+
t.Fatalf("want 2 subs, got %d", fo.count())
22+
}
23+
24+
entries := []Entry{
25+
{ID: "x", Content: "test"},
26+
}
27+
fo.broadcast(entries)
28+
29+
select {
30+
case got := <-ch1:
31+
if got[0].ID != "x" {
32+
t.Errorf("ch1: want ID 'x', got %q", got[0].ID)
33+
}
34+
case <-time.After(time.Second):
35+
t.Fatal("ch1: timeout")
36+
}
37+
38+
select {
39+
case got := <-ch2:
40+
if got[0].ID != "x" {
41+
t.Errorf("ch2: want ID 'x', got %q", got[0].ID)
42+
}
43+
case <-time.After(time.Second):
44+
t.Fatal("ch2: timeout")
45+
}
46+
}
47+
48+
func TestFanOut_Unsubscribe(t *testing.T) {
49+
fo := newFanOut()
50+
ch := fo.subscribe()
51+
fo.unsubscribe(ch)
52+
53+
if fo.count() != 0 {
54+
t.Errorf("want 0 subs after unsubscribe, got %d",
55+
fo.count())
56+
}
57+
}
58+
59+
func TestFanOut_BroadcastToNone(t *testing.T) {
60+
fo := newFanOut()
61+
// Should not panic.
62+
fo.broadcast([]Entry{{ID: "noop"}})
63+
}

internal/hub/testhelper_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// / ctx: https://ctx.ist
2+
// ,'`./ do you remember?
3+
// `.,'\
4+
// \ Copyright 2026-present Context contributors.
5+
// SPDX-License-Identifier: Apache-2.0
6+
7+
package hub
8+
9+
import (
10+
"context"
11+
"net"
12+
"testing"
13+
)
14+
15+
// testCtx returns a background context for test RPCs.
16+
func testCtx() context.Context {
17+
return context.Background()
18+
}
19+
20+
// listenRandom returns a TCP listener on a random port.
21+
func listenRandom(t *testing.T) net.Listener {
22+
t.Helper()
23+
lis, lisErr := net.Listen("tcp", "127.0.0.1:0")
24+
if lisErr != nil {
25+
t.Fatal(lisErr)
26+
}
27+
return lis
28+
}

0 commit comments

Comments
 (0)