Skip to content

Commit 56dff4a

Browse files
committed
dev: unit test
1 parent 022d59c commit 56dff4a

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright 2019 Altinity Ltd and/or its affiliates. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package chi
16+
17+
import (
18+
"context"
19+
"errors"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
apps "k8s.io/api/apps/v1"
25+
apiErrors "k8s.io/apimachinery/pkg/api/errors"
26+
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/runtime/schema"
28+
29+
api "github.com/altinity/clickhouse-operator/pkg/apis/clickhouse.altinity.com/v1"
30+
a "github.com/altinity/clickhouse-operator/pkg/controller/common/announcer"
31+
"github.com/altinity/clickhouse-operator/pkg/interfaces"
32+
)
33+
34+
var deleteHostSTSResource = schema.GroupResource{Group: "apps", Resource: "statefulsets"}
35+
36+
// deleteHostFakeSTS answers deleteHost's first step - the StatefulSet lookup. Every
37+
// mutating method panics: reaching one means deleteHost went on to touch k8s objects
38+
// after a Get it could not classify, which is exactly what these tests forbid.
39+
type deleteHostFakeSTS struct {
40+
getCalls int
41+
getErr error
42+
}
43+
44+
func (f *deleteHostFakeSTS) Get(ctx context.Context, params ...any) (*apps.StatefulSet, error) {
45+
f.getCalls++
46+
if f.getErr != nil {
47+
// Mirror client-go: a typed Get hands back a non-nil zero object alongside the error.
48+
return &apps.StatefulSet{}, f.getErr
49+
}
50+
return &apps.StatefulSet{}, nil
51+
}
52+
53+
func (f *deleteHostFakeSTS) Create(ctx context.Context, sts *apps.StatefulSet) (*apps.StatefulSet, error) {
54+
panic("deleteHost must not create a StatefulSet when the StatefulSet Get failed")
55+
}
56+
57+
func (f *deleteHostFakeSTS) Update(ctx context.Context, sts *apps.StatefulSet) (*apps.StatefulSet, error) {
58+
panic("deleteHost must not update a StatefulSet when the StatefulSet Get failed")
59+
}
60+
61+
func (f *deleteHostFakeSTS) Delete(ctx context.Context, namespace, name string) error {
62+
panic("deleteHost must not delete a StatefulSet when the StatefulSet Get failed")
63+
}
64+
65+
func (f *deleteHostFakeSTS) List(ctx context.Context, namespace string, opts meta.ListOptions) ([]apps.StatefulSet, error) {
66+
return nil, nil
67+
}
68+
69+
// deleteHostFakeKube exposes STS() only. IKube is embedded as a nil interface, so any
70+
// other accessor panics - a second guard that the paths under test reach nothing else.
71+
type deleteHostFakeKube struct {
72+
interfaces.IKube
73+
sts interfaces.IKubeSTS
74+
}
75+
76+
func (k *deleteHostFakeKube) STS() interfaces.IKubeSTS { return k.sts }
77+
78+
// newDeleteHostFixture builds the smallest worker/CR/host trio that carries deleteHost
79+
// from entry down to the StatefulSet Get: a live context so the IsContextDone guard does
80+
// not short-circuit, a CR attached to the host so the DeleteStarted announce can format,
81+
// and an announcer with no event emitter - capable() is then false, so WithEvent and
82+
// friends stay inert and never reach the nil status updater.
83+
func newDeleteHostFixture(fake interfaces.IKubeSTS) (*worker, *api.ClickHouseInstallation, *api.Host) {
84+
const (
85+
namespace = "test-ns"
86+
crName = "test-chi"
87+
clusterName = "cluster"
88+
hostName = "chi-test-chi-cluster-0-0"
89+
)
90+
91+
cr := &api.ClickHouseInstallation{
92+
ObjectMeta: meta.ObjectMeta{Namespace: namespace, Name: crName},
93+
}
94+
host := &api.Host{Name: hostName}
95+
host.Runtime.Address.Namespace = namespace
96+
host.Runtime.Address.CHIName = crName
97+
host.Runtime.Address.ClusterName = clusterName
98+
host.Runtime.Address.HostName = hostName
99+
host.Runtime.SetCR(cr)
100+
101+
w := &worker{
102+
c: &Controller{kube: &deleteHostFakeKube{sts: fake}},
103+
a: a.NewAnnouncer(nil, nil),
104+
}
105+
return w, cr, host
106+
}
107+
108+
// TestDeleteHostStatefulSetGetErrorClassification pins how deleteHost reads a failed
109+
// StatefulSet Get.
110+
//
111+
// Only IsNotFound proves the host is gone. Every other error means "unable to tell", and
112+
// reporting those as a completed deletion drops the host's objects on the floor: the
113+
// cleanup below the Get is skipped, and the host's PVCs carry no owner reference
114+
// (model/common/creator/pvc.go), so nothing else ever reclaims them.
115+
func TestDeleteHostStatefulSetGetErrorClassification(t *testing.T) {
116+
tests := []struct {
117+
name string
118+
injected error
119+
wantErr bool
120+
}{
121+
{
122+
name: "NotFound - StatefulSet is gone for sure, host already deleted",
123+
injected: apiErrors.NewNotFound(deleteHostSTSResource, "chi-test-chi-cluster-0-0"),
124+
},
125+
{
126+
name: "Forbidden - RBAC revoked, existence of the StatefulSet is unknown",
127+
injected: apiErrors.NewForbidden(deleteHostSTSResource, "chi-test-chi-cluster-0-0", errors.New("no permission")),
128+
wantErr: true,
129+
},
130+
{
131+
name: "InternalError - API server failure, existence of the StatefulSet is unknown",
132+
injected: apiErrors.NewInternalError(errors.New("etcd unavailable")),
133+
wantErr: true,
134+
},
135+
{
136+
name: "TooManyRequests - throttled, existence of the StatefulSet is unknown",
137+
injected: apiErrors.NewTooManyRequests("client throttled", 1),
138+
wantErr: true,
139+
},
140+
{
141+
name: "opaque non-API error - transport failure, not classifiable at all",
142+
injected: errors.New("dial tcp 10.96.0.1:443: connect: connection refused"),
143+
wantErr: true,
144+
},
145+
}
146+
147+
for _, tc := range tests {
148+
t.Run(tc.name, func(t *testing.T) {
149+
fake := &deleteHostFakeSTS{getErr: tc.injected}
150+
w, cr, host := newDeleteHostFixture(fake)
151+
152+
err := w.deleteHost(context.Background(), cr, host)
153+
154+
// Classification first: it is the behaviour under test, and asserting it before
155+
// the invariants below keeps a failure here attributable to the classification
156+
// rather than to an unrelated invariant tripping first.
157+
if tc.wantErr {
158+
require.Error(t, err, "an unclassifiable Get error must not be reported as a completed deletion")
159+
require.ErrorIs(t, err, tc.injected, "the original API error must reach the caller")
160+
} else {
161+
require.NoError(t, err, "a NotFound StatefulSet means the host is already deleted")
162+
}
163+
164+
// Invariants that hold for every class. assert, not require, so one failing
165+
// invariant still reports the other.
166+
assert.Equal(t, 1, fake.getCalls, "deleteHost must consult the StatefulSet exactly once")
167+
// A Get that failed read nothing, so the host must not be left holding a
168+
// zero-valued or stale StatefulSet that later steps could act on.
169+
assert.Nil(t, host.Runtime.CurStatefulSet, "a failed Get must not leave a StatefulSet behind")
170+
})
171+
}
172+
}

0 commit comments

Comments
 (0)