package minio import ( "context" "errors" "go/ast" "go/parser" "go/token" "io" "net/http" "os" "strings" "testing" "time" miniogo "github.com/minio/minio-go/v7" "code.nochebuena.dev/einherjar/contracts/lifecycle" "code.nochebuena.dev/einherjar/contracts/logging" "code.nochebuena.dev/einherjar/contracts/observability" "code.nochebuena.dev/einherjar/core/xerrors" ) // Compile-time interface checks (CT-5 / I-8). var _ lifecycle.Component = (Component)(nil) var _ observability.Checkable = (Component)(nil) var _ Provider = (Component)(nil) // --- CT-6: at most one exported TypeSpec per non-test file --- func TestAtMostOneExportedTypePerFile(t *testing.T) { fset := token.NewFileSet() entries, err := os.ReadDir(".") if err != nil { t.Fatalf("ReadDir: %v", err) } for _, entry := range entries { name := entry.Name() if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } f, err := parser.ParseFile(fset, name, nil, 0) if err != nil { t.Fatalf("parse %s: %v", name, err) } count := 0 for _, decl := range f.Decls { gd, ok := decl.(*ast.GenDecl) if !ok { continue } for _, spec := range gd.Specs { ts, ok := spec.(*ast.TypeSpec) if !ok { continue } if ts.Name.IsExported() { count++ } } } if count > 1 { t.Errorf("%s: has %d exported TypeSpecs, want ≤1", name, count) } } } // --- Config defaults (S-4) --- func TestDefaultConfig_OptionalFields(t *testing.T) { cfg := DefaultConfig() if cfg.Region == "" { t.Error("Region must have a default") } } // --- HandleError --- func assertXCode(t *testing.T, err error, want xerrors.Code) { t.Helper() var xe *xerrors.Err if !errors.As(err, &xe) { t.Fatalf("expected *xerrors.Err, got %T: %v", err, err) } if xe.Code() != want { t.Errorf("want code %s, got %s", want, xe.Code()) } } func TestHandleError_Nil(t *testing.T) { if err := HandleError(nil); err != nil { t.Errorf("want nil, got %v", err) } } func TestHandleError_NoSuchBucket(t *testing.T) { assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.NoSuchBucket}), xerrors.ErrNotFound) } func TestHandleError_NoSuchKey(t *testing.T) { assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.NoSuchKey}), xerrors.ErrNotFound) } func TestHandleError_AccessDenied(t *testing.T) { assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.AccessDenied}), xerrors.ErrPermissionDenied) } func TestHandleError_InvalidAccessKeyID(t *testing.T) { assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.InvalidAccessKeyID}), xerrors.ErrPermissionDenied) } func TestHandleError_BucketAlreadyExists(t *testing.T) { assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.BucketAlreadyExists}), xerrors.ErrAlreadyExists) } func TestHandleError_BucketAlreadyOwnedByYou(t *testing.T) { assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.BucketAlreadyOwnedByYou}), xerrors.ErrAlreadyExists) } func TestHandleError_Unknown(t *testing.T) { assertXCode(t, HandleError(miniogo.ErrorResponse{Code: "SomeUnknownCode"}), xerrors.ErrInternal) } // --- New / name / priority --- func TestNew_NotNil(t *testing.T) { if New(newLogger(), Config{}) == nil { t.Fatal("New returned nil") } } func TestComponent_Name(t *testing.T) { c := New(newLogger(), Config{}) if c.Name() != "minio" { t.Errorf("Name() = %q, want %q", c.Name(), "minio") } } func TestComponent_Priority(t *testing.T) { c := New(newLogger(), Config{}) if c.Priority() != observability.LevelCritical { t.Error("Priority() != LevelCritical") } } // --- nil client guards --- func TestComponent_OnStop_NilClient(t *testing.T) { c := &minioImpl{logger: newLogger()} if err := c.OnStop(); err != nil { t.Errorf("OnStop with nil client: %v", err) } } func TestComponent_HealthCheck_NilClient(t *testing.T) { c := &minioImpl{logger: newLogger()} if err := c.HealthCheck(context.Background()); err == nil { t.Error("HealthCheck with nil client should return error") } } func TestComponent_HandleError_Passthrough(t *testing.T) { c := New(newLogger(), Config{}) if c.HandleError(nil) != nil { t.Error("HandleError(nil) should return nil") } } // --- OnInit and Native --- func TestComponent_OnInit_And_Native(t *testing.T) { cfg := testConfig(alwaysOK()) c := New(newLogger(), cfg) if err := c.OnInit(); err != nil { t.Fatalf("OnInit: %v", err) } if c.Native() == nil { t.Error("Native() returned nil after OnInit") } } // --- OnStart --- func TestComponent_OnStart_BucketExists(t *testing.T) { putCalled := false cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPut { putCalled = true } return makeResp(http.StatusOK), nil }}) c := New(newLogger(), cfg) if err := c.OnInit(); err != nil { t.Fatalf("OnInit: %v", err) } if err := c.OnStart(); err != nil { t.Fatalf("OnStart: %v", err) } if putCalled { t.Error("MakeBucket should not be called when bucket already exists") } } func TestComponent_OnStart_BucketMissing(t *testing.T) { putCalled := false cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodHead { return makeResp(http.StatusNotFound), nil } if r.Method == http.MethodPut { putCalled = true return makeResp(http.StatusOK), nil } return makeResp(http.StatusOK), nil }}) c := New(newLogger(), cfg) if err := c.OnInit(); err != nil { t.Fatalf("OnInit: %v", err) } if err := c.OnStart(); err != nil { t.Fatalf("OnStart: %v", err) } if !putCalled { t.Error("MakeBucket should be called when bucket does not exist") } } func TestComponent_OnStart_BucketError(t *testing.T) { cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { return makeResp(http.StatusInternalServerError), nil }}) c := New(newLogger(), cfg) if err := c.OnInit(); err != nil { t.Fatalf("OnInit: %v", err) } if err := c.OnStart(); err == nil { t.Error("OnStart should return error on 500 response") } } // --- HealthCheck --- func TestComponent_HealthCheck_Unreachable(t *testing.T) { cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) { return nil, errors.New("connection refused") }}) c := New(newLogger(), cfg) if err := c.OnInit(); err != nil { t.Fatalf("OnInit: %v", err) } if err := c.HealthCheck(context.Background()); err == nil { t.Error("HealthCheck should return error when endpoint is unreachable") } } // --- Provider operations --- func startedComponent(t *testing.T, transport http.RoundTripper) Component { t.Helper() cfg := testConfig(transport) c := New(newLogger(), cfg) if err := c.OnInit(); err != nil { t.Fatalf("OnInit: %v", err) } return c } func TestComponent_PutObject_Success(t *testing.T) { c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) { resp := makeResp(http.StatusOK) resp.Header.Set("ETag", `"abc123"`) return resp, nil }}) _, err := c.PutObject(context.Background(), "test-bucket", "products/1/img.jpg", strings.NewReader("data"), 4, miniogo.PutObjectOptions{}) if err != nil { t.Errorf("PutObject: %v", err) } } func TestComponent_RemoveObject_Success(t *testing.T) { c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) { return makeResp(http.StatusNoContent), nil }}) if err := c.RemoveObject(context.Background(), "test-bucket", "products/1/img.jpg", miniogo.RemoveObjectOptions{}); err != nil { t.Errorf("RemoveObject: %v", err) } } func TestComponent_GetObject_Success(t *testing.T) { c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) { resp := makeResp(http.StatusOK) resp.Body = io.NopCloser(strings.NewReader("image-bytes")) resp.ContentLength = 11 return resp, nil }}) obj, err := c.GetObject(context.Background(), "test-bucket", "products/1/img.jpg", miniogo.GetObjectOptions{}) if err != nil { t.Fatalf("GetObject: %v", err) } if obj == nil { t.Error("GetObject returned nil object") } obj.Close() } func TestComponent_PresignedGetObject_Success(t *testing.T) { c := startedComponent(t, nil) u, err := c.PresignedGetObject(context.Background(), "test-bucket", "products/1/img.jpg", time.Hour, nil) if err != nil { t.Errorf("PresignedGetObject: %v", err) } if u == nil { t.Error("PresignedGetObject returned nil URL") } } func TestComponent_OnStop_NilsClient(t *testing.T) { c := startedComponent(t, alwaysOK()) if err := c.OnStop(); err != nil { t.Errorf("OnStop: %v", err) } if c.Native() != nil { t.Error("Native() should return nil after OnStop") } } // --- stubs and helpers --- type stubLogger struct{} func newLogger() *stubLogger { return &stubLogger{} } func (s *stubLogger) Debug(msg string, args ...any) {} func (s *stubLogger) Info(msg string, args ...any) {} func (s *stubLogger) Warn(msg string, args ...any) {} func (s *stubLogger) Error(msg string, err error, args ...any) {} func (s *stubLogger) With(args ...any) logging.Logger { return s } func (s *stubLogger) WithContext(ctx context.Context) logging.Logger { return s } type mockTransport struct { fn func(*http.Request) (*http.Response, error) } func (m *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) { return m.fn(r) } func makeResp(status int) *http.Response { return &http.Response{ StatusCode: status, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header), Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, } } func alwaysOK() *mockTransport { return &mockTransport{fn: func(r *http.Request) (*http.Response, error) { return makeResp(http.StatusOK), nil }} } func testConfig(transport http.RoundTripper) Config { return Config{ Endpoint: "localhost:9000", AccessKey: "minioadmin", SecretKey: "minioadmin", Bucket: "test-bucket", Region: "us-east-1", Transport: transport, } }