Skip to content

Commit 31d93eb

Browse files
test: unit tests for controller reconcilers (#40 #41 #42 #43)
Closes #40, #41, #42, #43.
1 parent 97e88e0 commit 31d93eb

8 files changed

Lines changed: 1222 additions & 1 deletion

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
Copyright 2026 The Faros Authors.
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 scheduler
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/runtime"
25+
"k8s.io/apimachinery/pkg/types"
26+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
27+
28+
kedgev1alpha1 "github.com/faroshq/faros-kedge/apis/kedge/v1alpha1"
29+
"github.com/faroshq/faros-kedge/utils/testfakes"
30+
)
31+
32+
func newSchedulerScheme(t *testing.T) *runtime.Scheme {
33+
t.Helper()
34+
s := runtime.NewScheme()
35+
if err := kedgev1alpha1.AddToScheme(s); err != nil {
36+
t.Fatalf("adding kedge scheme: %v", err)
37+
}
38+
return s
39+
}
40+
41+
func vw(name, ns string, selector map[string]string, strategy kedgev1alpha1.PlacementStrategy) *kedgev1alpha1.VirtualWorkload {
42+
return &kedgev1alpha1.VirtualWorkload{
43+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, UID: types.UID(name + "-uid")},
44+
Spec: kedgev1alpha1.VirtualWorkloadSpec{
45+
Placement: kedgev1alpha1.PlacementSpec{
46+
Strategy: strategy,
47+
SiteSelector: &metav1.LabelSelector{
48+
MatchLabels: selector,
49+
},
50+
},
51+
},
52+
}
53+
}
54+
55+
func site(name string, labels map[string]string) *kedgev1alpha1.Site {
56+
return &kedgev1alpha1.Site{
57+
ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels},
58+
}
59+
}
60+
61+
func TestSchedulerReconciler_VWNotFound(t *testing.T) {
62+
scheme := newSchedulerScheme(t)
63+
c := fake.NewClientBuilder().WithScheme(scheme).Build()
64+
r := &Reconciler{mgr: testfakes.NewManager(c)}
65+
66+
result, err := r.Reconcile(context.Background(), testfakes.NewRequest("cluster", "default", "ghost"))
67+
if err != nil {
68+
t.Fatalf("expected no error, got: %v", err)
69+
}
70+
if result.RequeueAfter != 0 {
71+
t.Errorf("expected empty Result for not-found VW, got: %+v", result)
72+
}
73+
}
74+
75+
func TestSchedulerReconciler_NoMatchingSites(t *testing.T) {
76+
scheme := newSchedulerScheme(t)
77+
workload := vw("vw-1", "default", map[string]string{"env": "prod"}, kedgev1alpha1.PlacementStrategySpread)
78+
s1 := site("site-staging", map[string]string{"env": "staging"})
79+
80+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload, s1).Build()
81+
r := &Reconciler{mgr: testfakes.NewManager(c)}
82+
83+
_, err := r.Reconcile(context.Background(), testfakes.NewRequest("cluster", "default", "vw-1"))
84+
if err != nil {
85+
t.Fatalf("unexpected error: %v", err)
86+
}
87+
88+
var placements kedgev1alpha1.PlacementList
89+
if err := c.List(context.Background(), &placements); err != nil {
90+
t.Fatalf("list placements: %v", err)
91+
}
92+
if len(placements.Items) != 0 {
93+
t.Errorf("expected 0 placements for no matching sites, got %d", len(placements.Items))
94+
}
95+
}
96+
97+
func TestSchedulerReconciler_MatchingSites_PlacementsCreated(t *testing.T) {
98+
scheme := newSchedulerScheme(t)
99+
workload := vw("vw-2", "default", map[string]string{"env": "prod"}, kedgev1alpha1.PlacementStrategySpread)
100+
s1 := site("site-prod-1", map[string]string{"env": "prod"})
101+
s2 := site("site-prod-2", map[string]string{"env": "prod"})
102+
s3 := site("site-staging", map[string]string{"env": "staging"})
103+
104+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload, s1, s2, s3).Build()
105+
r := &Reconciler{mgr: testfakes.NewManager(c)}
106+
107+
_, err := r.Reconcile(context.Background(), testfakes.NewRequest("cluster", "default", "vw-2"))
108+
if err != nil {
109+
t.Fatalf("unexpected error: %v", err)
110+
}
111+
112+
var placements kedgev1alpha1.PlacementList
113+
if err := c.List(context.Background(), &placements); err != nil {
114+
t.Fatalf("list: %v", err)
115+
}
116+
if len(placements.Items) != 2 {
117+
t.Errorf("expected 2 placements (one per prod site), got %d", len(placements.Items))
118+
}
119+
for _, p := range placements.Items {
120+
if p.Spec.WorkloadRef.Name != "vw-2" {
121+
t.Errorf("placement workloadRef.Name=%q, want %q", p.Spec.WorkloadRef.Name, "vw-2")
122+
}
123+
if p.Labels["kedge.faros.sh/virtualworkload"] != "vw-2" {
124+
t.Errorf("placement missing virtualworkload label")
125+
}
126+
}
127+
}
128+
129+
func TestSchedulerReconciler_Idempotent(t *testing.T) {
130+
scheme := newSchedulerScheme(t)
131+
workload := vw("vw-3", "default", map[string]string{"env": "prod"}, kedgev1alpha1.PlacementStrategySpread)
132+
s1 := site("site-prod-a", map[string]string{"env": "prod"})
133+
134+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload, s1).Build()
135+
r := &Reconciler{mgr: testfakes.NewManager(c)}
136+
137+
req := testfakes.NewRequest("cluster", "default", "vw-3")
138+
if _, err := r.Reconcile(context.Background(), req); err != nil {
139+
t.Fatalf("first reconcile: %v", err)
140+
}
141+
if _, err := r.Reconcile(context.Background(), req); err != nil {
142+
t.Fatalf("second reconcile: %v", err)
143+
}
144+
145+
var placements kedgev1alpha1.PlacementList
146+
if err := c.List(context.Background(), &placements); err != nil {
147+
t.Fatalf("list: %v", err)
148+
}
149+
if len(placements.Items) != 1 {
150+
t.Errorf("expected exactly 1 placement after idempotent reconcile, got %d", len(placements.Items))
151+
}
152+
}
153+
154+
func TestSchedulerReconciler_StalePlacementDeleted(t *testing.T) {
155+
scheme := newSchedulerScheme(t)
156+
workload := vw("vw-4", "default", map[string]string{"env": "prod"}, kedgev1alpha1.PlacementStrategySpread)
157+
s1 := site("site-prod-x", map[string]string{"env": "prod"})
158+
stalePlacement := &kedgev1alpha1.Placement{
159+
ObjectMeta: metav1.ObjectMeta{
160+
Name: "vw-4-site-gone",
161+
Namespace: "default",
162+
Labels: map[string]string{
163+
"kedge.faros.sh/virtualworkload": "vw-4",
164+
"kedge.faros.sh/site": "site-gone",
165+
},
166+
},
167+
Spec: kedgev1alpha1.PlacementObjSpec{SiteName: "site-gone"},
168+
}
169+
170+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload, s1, stalePlacement).Build()
171+
r := &Reconciler{mgr: testfakes.NewManager(c)}
172+
173+
_, err := r.Reconcile(context.Background(), testfakes.NewRequest("cluster", "default", "vw-4"))
174+
if err != nil {
175+
t.Fatalf("unexpected error: %v", err)
176+
}
177+
178+
var placements kedgev1alpha1.PlacementList
179+
if err := c.List(context.Background(), &placements); err != nil {
180+
t.Fatalf("list: %v", err)
181+
}
182+
for _, p := range placements.Items {
183+
if p.Spec.SiteName == "site-gone" {
184+
t.Errorf("stale placement for site-gone was not deleted")
185+
}
186+
}
187+
}
188+
189+
func TestSchedulerReconciler_SingletonStrategy(t *testing.T) {
190+
scheme := newSchedulerScheme(t)
191+
workload := vw("vw-5", "default", map[string]string{"env": "prod"}, kedgev1alpha1.PlacementStrategySingleton)
192+
s1 := site("site-1", map[string]string{"env": "prod"})
193+
s2 := site("site-2", map[string]string{"env": "prod"})
194+
s3 := site("site-3", map[string]string{"env": "prod"})
195+
196+
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workload, s1, s2, s3).Build()
197+
r := &Reconciler{mgr: testfakes.NewManager(c)}
198+
199+
_, err := r.Reconcile(context.Background(), testfakes.NewRequest("cluster", "default", "vw-5"))
200+
if err != nil {
201+
t.Fatalf("unexpected error: %v", err)
202+
}
203+
204+
var placements kedgev1alpha1.PlacementList
205+
if err := c.List(context.Background(), &placements); err != nil {
206+
t.Fatalf("list: %v", err)
207+
}
208+
if len(placements.Items) != 1 {
209+
t.Errorf("singleton strategy: expected exactly 1 placement for 3 matched sites, got %d", len(placements.Items))
210+
}
211+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
Copyright 2026 The Faros Authors.
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 site
18+
19+
import (
20+
"context"
21+
"testing"
22+
"time"
23+
24+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
"k8s.io/apimachinery/pkg/runtime"
26+
"k8s.io/apimachinery/pkg/types"
27+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
28+
29+
kedgev1alpha1 "github.com/faroshq/faros-kedge/apis/kedge/v1alpha1"
30+
"github.com/faroshq/faros-kedge/utils/testfakes"
31+
)
32+
33+
func newLifecycleScheme(t *testing.T) *runtime.Scheme {
34+
t.Helper()
35+
s := runtime.NewScheme()
36+
if err := kedgev1alpha1.AddToScheme(s); err != nil {
37+
t.Fatalf("adding kedge scheme: %v", err)
38+
}
39+
return s
40+
}
41+
42+
func TestLifecycleReconciler_SiteNotFound(t *testing.T) {
43+
scheme := newLifecycleScheme(t)
44+
c := fake.NewClientBuilder().WithScheme(scheme).Build()
45+
r := &LifecycleReconciler{mgr: testfakes.NewManager(c)}
46+
47+
result, err := r.Reconcile(context.Background(), testfakes.NewRequest("test-cluster", "", "missing-site"))
48+
if err != nil {
49+
t.Fatalf("expected no error, got: %v", err)
50+
}
51+
if result.RequeueAfter != 0 {
52+
t.Errorf("expected empty Result, got: %+v", result)
53+
}
54+
}
55+
56+
func TestLifecycleReconciler_NoHeartbeat(t *testing.T) {
57+
scheme := newLifecycleScheme(t)
58+
site := &kedgev1alpha1.Site{
59+
ObjectMeta: metav1.ObjectMeta{Name: "site-a"},
60+
Status: kedgev1alpha1.SiteStatus{LastHeartbeatTime: nil},
61+
}
62+
c := fake.NewClientBuilder().
63+
WithScheme(scheme).
64+
WithStatusSubresource(site).
65+
WithObjects(site).
66+
Build()
67+
r := &LifecycleReconciler{mgr: testfakes.NewManager(c)}
68+
69+
result, err := r.Reconcile(context.Background(), testfakes.NewRequest("test-cluster", "", "site-a"))
70+
if err != nil {
71+
t.Fatalf("expected no error, got: %v", err)
72+
}
73+
if result.RequeueAfter != 30*time.Second {
74+
t.Errorf("expected RequeueAfter 30s, got: %v", result.RequeueAfter)
75+
}
76+
77+
var updated kedgev1alpha1.Site
78+
if err := c.Get(context.Background(), types.NamespacedName{Name: "site-a"}, &updated); err != nil {
79+
t.Fatalf("get: %v", err)
80+
}
81+
if updated.Status.Phase != "" {
82+
t.Errorf("expected phase unchanged (empty), got: %q", updated.Status.Phase)
83+
}
84+
}
85+
86+
func TestLifecycleReconciler_FreshHeartbeat(t *testing.T) {
87+
scheme := newLifecycleScheme(t)
88+
now := metav1.NewTime(time.Now().Add(-1 * time.Minute))
89+
site := &kedgev1alpha1.Site{
90+
ObjectMeta: metav1.ObjectMeta{Name: "site-b"},
91+
Status: kedgev1alpha1.SiteStatus{
92+
Phase: kedgev1alpha1.SitePhaseConnected,
93+
LastHeartbeatTime: &now,
94+
},
95+
}
96+
c := fake.NewClientBuilder().
97+
WithScheme(scheme).
98+
WithStatusSubresource(site).
99+
WithObjects(site).
100+
Build()
101+
r := &LifecycleReconciler{mgr: testfakes.NewManager(c)}
102+
103+
result, err := r.Reconcile(context.Background(), testfakes.NewRequest("test-cluster", "", "site-b"))
104+
if err != nil {
105+
t.Fatalf("expected no error, got: %v", err)
106+
}
107+
if result.RequeueAfter != 30*time.Second {
108+
t.Errorf("expected RequeueAfter 30s, got: %v", result.RequeueAfter)
109+
}
110+
111+
var updated kedgev1alpha1.Site
112+
if err := c.Get(context.Background(), types.NamespacedName{Name: "site-b"}, &updated); err != nil {
113+
t.Fatalf("get: %v", err)
114+
}
115+
if updated.Status.Phase != kedgev1alpha1.SitePhaseConnected {
116+
t.Errorf("expected phase unchanged (%q), got: %q", kedgev1alpha1.SitePhaseConnected, updated.Status.Phase)
117+
}
118+
}
119+
120+
func TestLifecycleReconciler_StaleHeartbeat(t *testing.T) {
121+
scheme := newLifecycleScheme(t)
122+
stale := metav1.NewTime(time.Now().Add(-10 * time.Minute)) // > HeartbeatTimeout (5m)
123+
site := &kedgev1alpha1.Site{
124+
ObjectMeta: metav1.ObjectMeta{Name: "site-c"},
125+
Status: kedgev1alpha1.SiteStatus{
126+
Phase: kedgev1alpha1.SitePhaseConnected,
127+
TunnelConnected: true,
128+
LastHeartbeatTime: &stale,
129+
},
130+
}
131+
c := fake.NewClientBuilder().
132+
WithScheme(scheme).
133+
WithStatusSubresource(site).
134+
WithObjects(site).
135+
Build()
136+
r := &LifecycleReconciler{mgr: testfakes.NewManager(c)}
137+
138+
_, err := r.Reconcile(context.Background(), testfakes.NewRequest("test-cluster", "", "site-c"))
139+
if err != nil {
140+
t.Fatalf("expected no error, got: %v", err)
141+
}
142+
143+
var updated kedgev1alpha1.Site
144+
if err := c.Get(context.Background(), types.NamespacedName{Name: "site-c"}, &updated); err != nil {
145+
t.Fatalf("get: %v", err)
146+
}
147+
if updated.Status.Phase != kedgev1alpha1.SitePhaseDisconnected {
148+
t.Errorf("expected phase Disconnected, got: %q", updated.Status.Phase)
149+
}
150+
if updated.Status.TunnelConnected {
151+
t.Error("expected TunnelConnected=false after stale heartbeat")
152+
}
153+
}
154+
155+
func TestLifecycleReconciler_AlreadyDisconnected(t *testing.T) {
156+
scheme := newLifecycleScheme(t)
157+
stale := metav1.NewTime(time.Now().Add(-10 * time.Minute))
158+
site := &kedgev1alpha1.Site{
159+
ObjectMeta: metav1.ObjectMeta{Name: "site-d"},
160+
Status: kedgev1alpha1.SiteStatus{
161+
Phase: kedgev1alpha1.SitePhaseDisconnected,
162+
TunnelConnected: false,
163+
LastHeartbeatTime: &stale,
164+
},
165+
}
166+
c := fake.NewClientBuilder().
167+
WithScheme(scheme).
168+
WithStatusSubresource(site).
169+
WithObjects(site).
170+
Build()
171+
r := &LifecycleReconciler{mgr: testfakes.NewManager(c)}
172+
173+
_, err := r.Reconcile(context.Background(), testfakes.NewRequest("test-cluster", "", "site-d"))
174+
if err != nil {
175+
t.Fatalf("expected no error, got: %v", err)
176+
}
177+
178+
var after kedgev1alpha1.Site
179+
if err := c.Get(context.Background(), types.NamespacedName{Name: "site-d"}, &after); err != nil {
180+
t.Fatalf("get: %v", err)
181+
}
182+
// ResourceVersion must not change — no status write for already-disconnected site.
183+
if after.ResourceVersion != site.ResourceVersion {
184+
t.Errorf("expected no status write for already-disconnected site, but ResourceVersion changed: %s → %s",
185+
site.ResourceVersion, after.ResourceVersion)
186+
}
187+
}

0 commit comments

Comments
 (0)