1package config
2
3import (
4 "context"
5 "github.com/bnema/neferafk/internal/ports"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10)
11
12func recv[T any](t *testing.T, ch <-chan T) T {
13 t.Helper()
14 select {
15 case v := <-ch:
16 return v
17 case <-time.After(2 * time.Second):
18 t.Fatal("watch timeout")
19 var z T
20 return z
21 }
22}
23func save(t *testing.T, path, text string) {
24 t.Helper()
25 tmp := path + ".new"
26 if err := os.WriteFile(tmp, []byte(text), 0600); err != nil {
27 t.Fatal(err)
28 }
29 if err := os.Rename(tmp, path); err != nil {
30 t.Fatal(err)
31 }
32}
33func TestWatchAtomicInvalidTransactionalAndSymlinkRetarget(t *testing.T) {
34 dir := t.TempDir()
35 targetDir := t.TempDir()
36 path := filepath.Join(dir, "config")
37 target := filepath.Join(targetDir, "target")
38 save(t, path, "lock.after=5m\n")
39 out := make(chan ports.ConfigChanged, 4)
40 errs := make(chan error, 4)
41 ready := make(chan struct{})
42 ctx, cancel := context.WithCancel(context.Background())
43 done := make(chan error, 1)
44 go func() { done <- Watch(ctx, path, out, errs, ready) }()
45 recv(t, ready)
46 if initial := recv(t, out); initial.Config.LockAfter != 5*time.Minute {
47 t.Fatal(initial)
48 }
49 save(t, path, "lock.after=2m\n")
50 if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
51 t.Fatal(got)
52 }
53 save(t, path, "lock.after=-1s\n")
54 recv(t, errs)
55 select {
56 case v := <-out:
57 t.Fatalf("invalid reload emitted %+v", v)
58 default:
59 }
60 save(t, target, "lock.after=3m\n")
61 if err := os.Symlink(target, path+".link"); err != nil {
62 t.Fatal(err)
63 }
64 if err := os.Rename(path+".link", path); err != nil {
65 t.Fatal(err)
66 }
67 if got := recv(t, out); got.Config.LockAfter != 3*time.Minute {
68 t.Fatal(got)
69 }
70 save(t, target, "lock.after=4m\n")
71 if got := recv(t, out); got.Config.LockAfter != 4*time.Minute {
72 t.Fatal(got)
73 }
74 cancel()
75 if err := recv(t, done); err != nil {
76 t.Fatal(err)
77 }
78}
79func TestWatchCancellationWhileDeliveryBlocked(t *testing.T) {
80 path := filepath.Join(t.TempDir(), "config")
81 save(t, path, "lock.after=5m\n")
82 out := make(chan ports.ConfigChanged)
83 ready := make(chan struct{})
84 ctx, cancel := context.WithCancel(context.Background())
85 done := make(chan error, 1)
86 go func() { done <- Watch(ctx, path, out, nil, ready) }()
87 recv(t, ready)
88 save(t, path, "lock.after=1m\n")
89 cancel()
90 if err := recv(t, done); err != nil {
91 t.Fatal(err)
92 }
93}
94
95func TestWatchMissingDirectoryStartupThenRemoveRecreate(t *testing.T) {
96 root := t.TempDir()
97 dir := filepath.Join(root, "missing", "nested")
98 path := filepath.Join(dir, "config")
99 out := make(chan ports.ConfigChanged, 4)
100 ready := make(chan struct{})
101 ctx, cancel := context.WithCancel(context.Background())
102 defer cancel()
103 done := make(chan error, 1)
104 go func() { done <- Watch(ctx, path, out, nil, ready) }()
105 recv(t, ready)
106 if err := os.MkdirAll(dir, 0700); err != nil {
107 t.Fatal(err)
108 }
109 save(t, path, "lock.after=1m\n")
110 if got := recv(t, out); got.Config.LockAfter != time.Minute {
111 t.Fatal(got)
112 }
113 // Rename the entire watched directory out of the path, then recreate it.
114 if err := os.Rename(dir, dir+"-old"); err != nil {
115 t.Fatal(err)
116 }
117 if err := os.MkdirAll(dir, 0700); err != nil {
118 t.Fatal(err)
119 }
120 save(t, path, "lock.after=2m\n")
121 if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
122 t.Fatal(got)
123 }
124 cancel()
125 if err := recv(t, done); err != nil {
126 t.Fatal(err)
127 }
128}
129
130func TestWatchRetargetToUnavailableThenReappearKeepsLastValid(t *testing.T) {
131 root := t.TempDir()
132 path := filepath.Join(root, "config")
133 save(t, path, "lock.after=1m\n")
134 out := make(chan ports.ConfigChanged, 4)
135 ready := make(chan struct{})
136 ctx, cancel := context.WithCancel(context.Background())
137 defer cancel()
138 done := make(chan error, 1)
139 go func() { done <- Watch(ctx, path, out, nil, ready) }()
140 recv(t, ready)
141 recv(t, out)
142 target := filepath.Join(t.TempDir(), "absent", "entry")
143 if err := os.Symlink(target, path+".link"); err != nil {
144 t.Fatal(err)
145 }
146 if err := os.Rename(path+".link", path); err != nil {
147 t.Fatal(err)
148 }
149 select {
150 case reset := <-out:
151 t.Fatalf("missing target reset last valid %+v", reset)
152 case err := <-done:
153 t.Fatalf("watch stopped on missing target %v", err)
154 case <-time.After(200 * time.Millisecond):
155 }
156 if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
157 t.Fatal(err)
158 }
159 save(t, target, "lock.after=3m\n")
160 if got := recv(t, out); got.Config.LockAfter != 3*time.Minute {
161 t.Fatal(got)
162 }
163 cancel()
164 if err := recv(t, done); err != nil {
165 t.Fatal(err)
166 }
167}
168
169func TestWatchFirstCandidateClosesLoadStartupGapAndMissingCancellation(t *testing.T) {
170 path := filepath.Join(t.TempDir(), "config")
171 save(t, path, "lock.after=1m\n")
172 old, err := Load(path)
173 if err != nil {
174 t.Fatal(err)
175 }
176 save(t, path, "lock.after=2m\n") // save after app's Load, before Watch
177 out := make(chan ports.ConfigChanged, 1)
178 ready := make(chan struct{})
179 ctx, cancel := context.WithCancel(context.Background())
180 done := make(chan error, 1)
181 go func() { done <- Watch(ctx, path, out, nil, ready) }()
182 recv(t, ready)
183 if got := recv(t, out); got.Config == old || got.Config.LockAfter != 2*time.Minute {
184 t.Fatal(got)
185 }
186 cancel()
187 if err := recv(t, done); err != nil {
188 t.Fatal(err)
189 }
190 ctx, cancel = context.WithCancel(context.Background())
191 done = make(chan error, 1)
192 ready = make(chan struct{})
193 go func() { done <- Watch(ctx, filepath.Join(t.TempDir(), "never", "config"), out, nil, ready) }()
194 recv(t, ready)
195 cancel()
196 if err := recv(t, done); err != nil {
197 t.Fatal(err)
198 }
199}