1
0
Fork 0
mirror of https://github.com/imjasonh/client-go2 synced 2026-07-13 11:27:10 +00:00
client-go2/generic/client_test.go
Jason Hall fb8305a316
Refactor to use rest.Client directly instead of dynamic client
This major refactoring eliminates the dependency on the dynamic client
and unstructured types, using rest.RESTClient directly for all operations.

Key changes:
- Replace dynamic.Interface with rest.RESTClient in client struct
- Remove all unstructured.Unstructured conversions
- Use direct JSON marshaling/unmarshaling for type conversions
- Update all CRUD methods (List, Get, Create, Update, Delete, Patch)
- Reimplement Inform to use cache.ListWatch with rest client
- Export Client type and update method signatures
- Add proper GroupVersion and APIPath configuration in NewClientGVR
- Update all tests to use mock HTTP transport instead of fake dynamic client
- Fix informer event handler to use new InformerHandler type
- Update example code to demonstrate new API

Benefits:
- More efficient: eliminates unnecessary type conversions
- Cleaner code: direct use of REST client
- Better performance: reduced JSON marshaling overhead
- Type safety maintained through generics

All unit tests and e2e tests pass against a real Kubernetes cluster.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-27 16:33:45 -04:00

624 lines
14 KiB
Go

package generic
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
)
// mockTransport implements http.RoundTripper for testing
type mockTransport struct {
responses map[string]mockResponse
}
type mockResponse struct {
statusCode int
body string
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
key := req.Method + " " + req.URL.Path
if resp, ok := m.responses[key]; ok {
return &http.Response{
StatusCode: resp.statusCode,
Body: io.NopCloser(strings.NewReader(resp.body)),
Header: make(http.Header),
}, nil
}
return &http.Response{
StatusCode: 404,
Body: io.NopCloser(strings.NewReader(`{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"not found","code":404}`)),
Header: make(http.Header),
}, nil
}
func TestNewClientGVR(t *testing.T) {
gvr := schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "pods",
}
config := &rest.Config{
Host: "http://localhost",
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
client := NewClientGVR[*corev1.Pod](gvr, config)
if client.gvr != gvr {
t.Errorf("expected GVR %v, got %v", gvr, client.gvr)
}
}
func TestList(t *testing.T) {
ctx := context.Background()
namespace := "test-namespace"
pod1 := &corev1.Pod{
TypeMeta: metav1.TypeMeta{
Kind: "Pod",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "pod1",
Namespace: namespace,
},
}
pod2 := &corev1.Pod{
TypeMeta: metav1.TypeMeta{
Kind: "Pod",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "pod2",
Namespace: namespace,
},
}
podList := &corev1.PodList{
TypeMeta: metav1.TypeMeta{
Kind: "PodList",
APIVersion: "v1",
},
Items: []corev1.Pod{*pod1, *pod2},
}
listJSON, _ := json.Marshal(podList)
transport := &mockTransport{
responses: map[string]mockResponse{
"GET /api/v1/namespaces/test-namespace/pods": {
statusCode: 200,
body: string(listJSON),
},
},
}
config := &rest.Config{
Host: "http://localhost",
APIPath: "/api",
Transport: transport,
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
restClient, err := rest.RESTClientFor(config)
if err != nil {
t.Fatal(err)
}
gvr := schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "pods",
}
client := Client[*corev1.Pod]{
gvr: gvr,
restClient: restClient,
}
pods, err := client.List(ctx, namespace)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(pods) != 2 {
t.Errorf("expected 2 pods, got %d", len(pods))
}
podNames := make(map[string]bool)
for _, pod := range pods {
podNames[pod.Name] = true
}
if !podNames["pod1"] || !podNames["pod2"] {
t.Error("expected pods not found")
}
}
func TestGet(t *testing.T) {
ctx := context.Background()
namespace := "test-namespace"
podName := "test-pod"
expectedPod := &corev1.Pod{
TypeMeta: metav1.TypeMeta{
Kind: "Pod",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: podName,
Namespace: namespace,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "nginx",
Image: "nginx:latest",
},
},
},
}
podJSON, _ := json.Marshal(expectedPod)
transport := &mockTransport{
responses: map[string]mockResponse{
"GET /api/v1/namespaces/test-namespace/pods/test-pod": {
statusCode: 200,
body: string(podJSON),
},
},
}
config := &rest.Config{
Host: "http://localhost",
APIPath: "/api",
Transport: transport,
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
restClient, err := rest.RESTClientFor(config)
if err != nil {
t.Fatal(err)
}
gvr := schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "pods",
}
client := Client[*corev1.Pod]{
gvr: gvr,
restClient: restClient,
}
pod, err := client.Get(ctx, namespace, podName)
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if pod.Name != podName {
t.Errorf("expected pod name %s, got %s", podName, pod.Name)
}
if len(pod.Spec.Containers) != 1 || pod.Spec.Containers[0].Name != "nginx" {
t.Error("pod spec doesn't match expected")
}
}
func TestCreate(t *testing.T) {
ctx := context.Background()
namespace := "test-namespace"
newPod := &corev1.Pod{
TypeMeta: metav1.TypeMeta{
Kind: "Pod",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "new-pod",
Namespace: namespace,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "nginx",
Image: "nginx:latest",
},
},
},
}
podJSON, _ := json.Marshal(newPod)
transport := &mockTransport{
responses: map[string]mockResponse{
"POST /api/v1/namespaces/test-namespace/pods": {
statusCode: 201,
body: string(podJSON),
},
"GET /api/v1/namespaces/test-namespace/pods/new-pod": {
statusCode: 200,
body: string(podJSON),
},
},
}
config := &rest.Config{
Host: "http://localhost",
APIPath: "/api",
Transport: transport,
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
restClient, err := rest.RESTClientFor(config)
if err != nil {
t.Fatal(err)
}
gvr := schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "pods",
}
client := Client[*corev1.Pod]{
gvr: gvr,
restClient: restClient,
}
created, err := client.Create(ctx, namespace, newPod)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if created.Name != "new-pod" {
t.Errorf("expected pod name new-pod, got %s", created.Name)
}
// Verify the pod was created
fetched, err := client.Get(ctx, namespace, "new-pod")
if err != nil {
t.Fatalf("Failed to get created pod: %v", err)
}
if fetched.Name != "new-pod" {
t.Errorf("expected pod name new-pod, got %s", fetched.Name)
}
}
func TestUpdate(t *testing.T) {
ctx := context.Background()
namespace := "test-namespace"
originalPod := &corev1.Pod{
TypeMeta: metav1.TypeMeta{
Kind: "Pod",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "update-pod",
Namespace: namespace,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "nginx",
Image: "nginx:1.0",
},
},
},
}
// Update the pod
updatedPod := originalPod.DeepCopy()
updatedPod.Spec.Containers[0].Image = "nginx:2.0"
updatedJSON, _ := json.Marshal(updatedPod)
transport := &mockTransport{
responses: map[string]mockResponse{
"PUT /api/v1/namespaces/test-namespace/pods/update-pod": {
statusCode: 200,
body: string(updatedJSON),
},
"GET /api/v1/namespaces/test-namespace/pods/update-pod": {
statusCode: 200,
body: string(updatedJSON),
},
},
}
config := &rest.Config{
Host: "http://localhost",
APIPath: "/api",
Transport: transport,
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
restClient, err := rest.RESTClientFor(config)
if err != nil {
t.Fatal(err)
}
gvr := schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "pods",
}
client := Client[*corev1.Pod]{
gvr: gvr,
restClient: restClient,
}
updated, err := client.Update(ctx, namespace, updatedPod)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if updated.Spec.Containers[0].Image != "nginx:2.0" {
t.Errorf("expected image nginx:2.0, got %s", updated.Spec.Containers[0].Image)
}
// Verify the update
result, err := client.Get(ctx, namespace, "update-pod")
if err != nil {
t.Fatalf("Failed to get updated pod: %v", err)
}
if result.Spec.Containers[0].Image != "nginx:2.0" {
t.Errorf("expected image nginx:2.0, got %s", result.Spec.Containers[0].Image)
}
}
func TestDelete(t *testing.T) {
ctx := context.Background()
namespace := "test-namespace"
transport := &mockTransport{
responses: map[string]mockResponse{
"DELETE /api/v1/namespaces/test-namespace/pods/delete-pod": {
statusCode: 200,
body: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Success"}`,
},
},
}
config := &rest.Config{
Host: "http://localhost",
APIPath: "/api",
Transport: transport,
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
restClient, err := rest.RESTClientFor(config)
if err != nil {
t.Fatal(err)
}
gvr := schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "pods",
}
client := Client[*corev1.Pod]{
gvr: gvr,
restClient: restClient,
}
// Delete the pod
if err := client.Delete(ctx, namespace, "delete-pod"); err != nil {
t.Fatalf("Delete failed: %v", err)
}
}
func TestPatch(t *testing.T) {
ctx := context.Background()
namespace := "test-namespace"
pod := &corev1.Pod{
TypeMeta: metav1.TypeMeta{
Kind: "Pod",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "patch-pod",
Namespace: namespace,
Labels: map[string]string{
"app": "test",
"environment": "production",
},
},
}
podJSON, _ := json.Marshal(pod)
transport := &mockTransport{
responses: map[string]mockResponse{
"PATCH /api/v1/namespaces/test-namespace/pods/patch-pod": {
statusCode: 200,
body: string(podJSON),
},
},
}
config := &rest.Config{
Host: "http://localhost",
APIPath: "/api",
Transport: transport,
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
restClient, err := rest.RESTClientFor(config)
if err != nil {
t.Fatal(err)
}
gvr := schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "pods",
}
client := Client[*corev1.Pod]{
gvr: gvr,
restClient: restClient,
}
// Create a JSON patch
patch := []map[string]interface{}{
{
"op": "add",
"path": "/metadata/labels/environment",
"value": "production",
},
}
patchData, err := json.Marshal(patch)
if err != nil {
t.Fatal(err)
}
if err := client.Patch(ctx, namespace, "patch-pod", types.JSONPatchType, patchData); err != nil {
t.Fatalf("Patch failed: %v", err)
}
}
// Test with ConfigMap to verify generic behavior
func TestGenericWithConfigMap(t *testing.T) {
ctx := context.Background()
namespace := "test-namespace"
cm := &corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{
Kind: "ConfigMap",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: namespace,
},
Data: map[string]string{
"key1": "value1",
"key2": "value2",
},
}
cmList := &corev1.ConfigMapList{
TypeMeta: metav1.TypeMeta{
Kind: "ConfigMapList",
APIVersion: "v1",
},
Items: []corev1.ConfigMap{*cm},
}
listJSON, _ := json.Marshal(cmList)
transport := &mockTransport{
responses: map[string]mockResponse{
"GET /api/v1/namespaces/test-namespace/configmaps": {
statusCode: 200,
body: string(listJSON),
},
},
}
config := &rest.Config{
Host: "http://localhost",
APIPath: "/api",
Transport: transport,
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
restClient, err := rest.RESTClientFor(config)
if err != nil {
t.Fatal(err)
}
gvr := schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "configmaps",
}
client := Client[*corev1.ConfigMap]{
gvr: gvr,
restClient: restClient,
}
// Test List
configs, err := client.List(ctx, namespace)
if err != nil {
t.Fatalf("List ConfigMaps failed: %v", err)
}
if len(configs) != 1 {
t.Errorf("expected 1 configmap, got %d", len(configs))
}
if configs[0].Data["key1"] != "value1" {
t.Errorf("expected key1=value1, got %s", configs[0].Data["key1"])
}
}
// TestNewClientGVRCustomResource tests using NewClientGVR with a custom GVR
func TestNewClientGVRCustomResource(t *testing.T) {
// Example: Using NewClientGVR for a custom resource that might not be in the scheme
customGVR := schema.GroupVersionResource{
Group: "custom.io",
Version: "v1",
Resource: "myresources",
}
config := &rest.Config{
Host: "http://localhost",
ContentConfig: rest.ContentConfig{
GroupVersion: &schema.GroupVersion{Group: "custom.io", Version: "v1"},
NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(),
},
}
client := NewClientGVR[*corev1.Pod](customGVR, config) // Using Pod type as placeholder
if client.gvr != customGVR {
t.Errorf("expected custom GVR %v, got %v", customGVR, client.gvr)
}
}