Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Components Availability API Endpoint #22

Merged
merged 10 commits into from
Dec 9, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
added acc and unit tests
sgmv authored and bakhterets committed Oct 29, 2024
commit caaec99eb0cec6ddf61fcb9579dc47f7f9f90860
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -5,8 +5,12 @@ SHELL=/bin/bash
SD_DB?="postgresql://pg:pass@localhost:5432/status_dashboard?sslmode=disable"

test:
@echo running tests
go test ./... -count 1
@echo running unit tests
go test ./internal/... -count 1

integ_test:
@echo running integrational tests with docker and db
go test ./tests/... -count 1

build:
@echo build app
58 changes: 57 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -7,6 +7,8 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/joho/godotenv v1.5.1
github.com/kelseyhightower/envconfig v1.4.0
github.com/lib/pq v1.10.9
github.com/ory/dockertest/v3 v3.11.0
github.com/stretchr/testify v1.9.0
go.uber.org/zap v1.27.0
gorm.io/driver/postgres v1.5.9
@@ -15,44 +17,98 @@ require (
)

require (
dario.cat/mergo v1.0.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/bytedance/sonic v1.12.1 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/containerd/continuity v0.4.3 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v27.3.1+incompatible // indirect
github.com/docker/docker v27.3.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.5.0 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runc v1.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/testcontainers/testcontainers-go v0.34.0 // indirect
github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.9.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
142 changes: 142 additions & 0 deletions go.sum

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion internal/api/errors/errors.go
Original file line number Diff line number Diff line change
@@ -26,8 +26,12 @@ var ErrInternalError = errors.New("internal server error")
var ErrIncidentDSNotExist = errors.New("incident does not exist")

var ErrComponentDSNotExist = errors.New("component does not exist")
var ErrComponentExist = errors.New("component already exists")
var ErrComponentInvalidFormat = errors.New("component invalid format")
var ErrComponentRegionAttrMissing = errors.New("component attribute region missing")
var ErrComponentAttrInvalidFormat = errors.New("component attribute has invalid format")
var ErrComponentRegionAttrMissing = errors.New("component attribute region is missing or invalid")
var ErrComponentTypeAttrMissing = errors.New("component attribute type is missing or invalid")
var ErrComponentCategoryAttrMissing = errors.New("component attribute category is missing or invalid")

func Return404(c *gin.Context) {
c.JSON(http.StatusNotFound, ReturnError(ErrPageNotFound))
5 changes: 2 additions & 3 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
@@ -22,10 +22,9 @@ func (a *API) InitRoutes() {
// setup v2 group routing
v2Api := a.r.Group(v2Group)
{
v2Api.GET("components", v2.GetComponentsStatusHandler(a.db, a.log))
v2Api.GET("components", v2.GetComponentsHandler(a.db, a.log))
v2Api.POST("components", v2.PostComponentHandler(a.db, a.log))
v2Api.GET("components/:id", v2.GetComponentHandler(a.db, a.log))
v2Api.GET("component_status", v2.GetComponentsStatusHandler(a.db, a.log))
v2Api.POST("component_status", v2.PostComponentStatusHandler(a.db, a.log))

v2Api.GET("incidents", v2.GetIncidentsHandler(a.db, a.log))
v2Api.POST("incidents", a.ValidateComponentsMW(), v2.PostIncidentHandler(a.db, a.log))
36 changes: 26 additions & 10 deletions internal/api/v1/v1.go
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
@@ -32,8 +33,8 @@ type IncidentData struct {
Impact *int `json:"impact" binding:"required,gte=0,lte=3"`
// datetime format is "2006-01-01 12:00"
StartDate SD2Time `json:"start_date" binding:"required"`
EndDate *SD2Time `json:"end_date,omitempty"`
Updates []*IncidentStatus `json:"updates,omitempty"`
EndDate *SD2Time `json:"end_date"`
Updates []*IncidentStatus `json:"updates"`
}

// IncidentStatus is a db table representation.
@@ -58,7 +59,16 @@ func (s *SD2Time) MarshalJSON() ([]byte, error) {
}

func (s *SD2Time) UnmarshalJSON(data []byte) error {
t, err := time.Parse(timeLayout, string(data))
strData := string(data)
if strData == "null" {
*s = SD2Time{}
return nil
}
if strings.HasPrefix(strData, "\"") {
runes := []rune(strData)
strData = string(runes[1 : len(runes)-1])
}
t, err := time.Parse(timeLayout, strData)
if err != nil {
return err
}
@@ -86,14 +96,19 @@ func GetIncidentsHandler(db *db.DB, logger *zap.Logger) gin.HandlerFunc {
}
}

endDate := SD2Time(*inc.EndDate)
var endDate *SD2Time
if inc.EndDate != nil {
sd2T := SD2Time(*inc.EndDate)
endDate = &sd2T
}

incidents[i] = &Incident{
IncidentID: IncidentID{int(inc.ID)},
IncidentData: IncidentData{
Text: *inc.Text,
Impact: inc.Impact,
StartDate: SD2Time(*inc.StartDate),
EndDate: &endDate,
EndDate: endDate,
Updates: updates,
},
}
@@ -142,9 +157,10 @@ func GetComponentsStatusHandler(db *db.DB, logger *zap.Logger) gin.HandlerFunc {

incidents := make([]*Incident, len(component.Incidents))
for i, inc := range component.Incidents {
var endDate SD2Time
var endDate *SD2Time
if inc.EndDate != nil {
endDate = SD2Time(*inc.EndDate)
sd2T := SD2Time(*inc.EndDate)
endDate = &sd2T
}

newInc := &Incident{
@@ -153,7 +169,7 @@ func GetComponentsStatusHandler(db *db.DB, logger *zap.Logger) gin.HandlerFunc {
Text: *inc.Text,
Impact: inc.Impact,
StartDate: SD2Time(*inc.StartDate),
EndDate: &endDate,
EndDate: endDate,
Updates: nil,
},
}
@@ -220,7 +236,7 @@ type ComponentStatusPost struct {
func PostComponentStatusHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc { //nolint:gocognit
return func(c *gin.Context) {
var inComponent ComponentStatusPost
attrs, err := extractComponentAttr(c, &inComponent)
attr, err := extractComponentAttr(c, &inComponent)
if err != nil {
apiErrors.RaiseBadRequestErr(c, err)
return
@@ -229,7 +245,7 @@ func PostComponentStatusHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFu
log := logger.With(zap.Any("component", inComponent))

log.Info("get component from name and attributes")
storedComponent, err := dbInst.GetComponentFromNameAttrs(inComponent.Name, attrs)
storedComponent, err := dbInst.GetComponentFromNameAttrs(inComponent.Name, attr)
if err != nil {
if errors.Is(err, db.ErrDBComponentDSNotExist) {
apiErrors.RaiseBadRequestErr(c, apiErrors.ErrComponentDSNotExist)
35 changes: 35 additions & 0 deletions internal/api/v1/v1_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package v1

import (
"encoding/json"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCustomTimeFormat(t *testing.T) {
timeRFC3339Str := "2024-09-01T11:45:26.371Z"
parsedTime, err := time.Parse(time.RFC3339, timeRFC3339Str)
require.NoError(t, err)
inc := &Incident{
IncidentData: IncidentData{
StartDate: SD2Time(parsedTime),
EndDate: nil,
},
}

data, err := json.Marshal(inc)
require.NoError(t, err)
assert.Equal(t, "{\"id\":0,\"text\":\"\",\"impact\":null,\"start_date\":\"2024-09-01 11:45\",\"end_date\":null,\"updates\":null}", string(data))

inc = &Incident{}
err = json.Unmarshal(data, &inc)
require.NoError(t, err)
assert.Equal(t, parsedTime.YearDay(), time.Time(inc.StartDate).YearDay())
assert.Equal(t, parsedTime.Hour(), time.Time(inc.StartDate).Hour())
assert.Equal(t, parsedTime.Minute(), time.Time(inc.StartDate).Minute())
assert.NotEqual(t, parsedTime.Second(), time.Time(inc.StartDate).Second())
assert.Nil(t, inc.EndDate)
}
131 changes: 102 additions & 29 deletions internal/api/v2/v2.go
Original file line number Diff line number Diff line change
@@ -110,6 +110,32 @@ func GetIncidentHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc {
}
}

// PostIncidentHandler creates an incident.
// TODO: copy-paste from the legacy, it's implemented, but only for API. We should discuss about this functionality.
//
// Process component status update and open new incident if required:
//
// - current active maintenance for the component - do nothing
// - current active incident for the component - do nothing
// - current active incident NOT for the component - add component into
// the list of affected components
// - no active incidents - create new one
// - current active incident for the component and requested
// impact > current impact - run handling:
//
// If a component exists in an incident, but the requested
// impact is higher than the current one, then the component
// will be moved to another incident if it exists with the
// requested impact, otherwise a new incident will be created
// and the component will be moved to the new incident.
// If there is only one component in an incident, and an
// incident with the requested impact does not exist,
// then the impact of the incident will be changed to a higher
// one, otherwise the component will be moved to an existing
// incident with the requested impact, and the current incident
// will be closed by the system.
// The movement of a component and the closure of an incident
// will be reflected in the incident statuses.
func PostIncidentHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
logger.Debug("create an incident")
@@ -223,12 +249,24 @@ type ComponentID struct {
ID int `json:"id" uri:"id" binding:"required,gte=0"`
}

// ComponentAttribute provides additional attributes for component.
// Available list of possible attributes are:
// 1. type
// 2. region
// 3. category
// All of them are required for creation.
type ComponentAttribute struct {
Name string `json:"name"`
Value string `json:"value"`
}

func GetComponentsStatusHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc {
var availableAttrs = map[string]struct{}{ //nolint:gochecknoglobals
"type": {},
"region": {},
"category": {},
}

func GetComponentsHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
logger.Debug("retrieve components")

@@ -266,34 +304,69 @@ func GetComponentHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc {
}
}

// PostComponentStatusHandler creates a new component.
// TODO: copy-paste from the legacy, it's implemented, but only for API. We should discuss about this functionality.
//
// Process component status update and open new incident if required:
//
// - current active maintenance for the component - do nothing
// - current active incident for the component - do nothing
// - current active incident NOT for the component - add component into
// the list of affected components
// - no active incidents - create new one
// - current active incident for the component and requested
// impact > current impact - run handling:
//
// If a component exists in an incident, but the requested
// impact is higher than the current one, then the component
// will be moved to another incident if it exists with the
// requested impact, otherwise a new incident will be created
// and the component will be moved to the new incident.
// If there is only one component in an incident, and an
// incident with the requested impact does not exist,
// then the impact of the incident will be changed to a higher
// one, otherwise the component will be moved to an existing
// incident with the requested impact, and the current incident
// will be closed by the system.
// The movement of a component and the closure of an incident
// will be reflected in the incident statuses.
func PostComponentStatusHandler(_ *db.DB, _ *zap.Logger) gin.HandlerFunc {
type PostComponentData struct {
Attributes []ComponentAttribute `json:"attrs" binding:"required"`
Name string `json:"name" binding:"required"`
}

// PostComponentHandler creates a new component.
func PostComponentHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, map[string]string{"status": "in development"})
logger.Debug("create a component")

var component PostComponentData
if err := c.ShouldBindBodyWithJSON(&component); err != nil {
apiErrors.RaiseBadRequestErr(c, err)
return
}

if err := checkComponentAttrs(component.Attributes); err != nil {
apiErrors.RaiseBadRequestErr(c, err)
return
}

attrs := make([]db.ComponentAttr, len(component.Attributes))
for i, attr := range component.Attributes {
attrs[i] = db.ComponentAttr{
Name: attr.Name,
Value: attr.Value,
}
}

compDB := &db.Component{
Name: component.Name,
Attrs: attrs,
}

componentID, err := dbInst.SaveComponent(compDB)
if err != nil {
if errors.Is(err, db.ErrDBComponentExists) {
apiErrors.RaiseBadRequestErr(c, apiErrors.ErrComponentExist)
}
apiErrors.RaiseInternalErr(c, err)
return
}

c.JSON(http.StatusCreated, Component{
ComponentID: ComponentID{int(componentID)},
Attributes: component.Attributes,
Name: component.Name,
})
}
}

func checkComponentAttrs(attrs []ComponentAttribute) error {
//nolint:nolintlint,mnd
// this magic number will be changed in the next iteration
if len(attrs) != 3 {
return apiErrors.ErrComponentAttrInvalidFormat
}
for _, attr := range attrs {
_, ok := availableAttrs[attr.Name]
if !ok {
return apiErrors.ErrComponentAttrInvalidFormat
}
}

return nil
}
6 changes: 3 additions & 3 deletions internal/api/v2/v2_test.go
Original file line number Diff line number Diff line change
@@ -71,10 +71,10 @@ func initRoutes(t *testing.T, c *gin.Engine, dbInst *db.DB, log *zap.Logger) {

v2Api := c.Group("v2")
{
v2Api.GET("components", GetComponentsStatusHandler(dbInst, log))
v2Api.GET("components", GetComponentsHandler(dbInst, log))
v2Api.GET("components/:id", GetComponentHandler(dbInst, log))
v2Api.GET("component_status", GetComponentsStatusHandler(dbInst, log))
v2Api.POST("component_status", PostComponentStatusHandler(dbInst, log))
v2Api.GET("component_status", GetComponentsHandler(dbInst, log))
v2Api.POST("component_status", PostComponentHandler(dbInst, log))

v2Api.GET("incidents", GetIncidentsHandler(dbInst, log))
v2Api.POST("incidents", PostIncidentHandler(dbInst, log))
47 changes: 40 additions & 7 deletions internal/db/db.go
Original file line number Diff line number Diff line change
@@ -189,27 +189,60 @@ func (db *DB) GetComponentsWithIncidents() ([]Component, error) {
return components, nil
}

func (db *DB) GetComponentFromNameAttrs(name string, attrs *ComponentAttr) (*Component, error) {
// GetComponentFromNameAttrs returns the Component from its name and region attribute.
func (db *DB) GetComponentFromNameAttrs(name string, attr *ComponentAttr) (*Component, error) {
comp := Component{}
//nolint:lll
// You can reproduce this raw request
// select * from component join component_attribute ca on component.id=ca.component_id
// where component.id =
// (select component.id from component join component_attribute ca on component.id = ca.component_id and ca.value='EU-DE' and component.name='Cloud Container Engine');
r := db.g.Model(&Component{}).
Where(&Component{Name: name}).
Preload("Attrs", func(db *gorm.DB) *gorm.DB {
return db.Where("name=?", attrs.Name).Where("value=?", attrs.Value)
}).
Preload("Attrs").Find(&comp)
subQuery := db.g.Model(&Component{}).
Select("component.id").
Joins("JOIN component_attribute ca ON ca.component_id = component.id").
Where("ca.value = ?", attr.Value).
Where("component.name = ?", name)
r := db.g.Model(&Component{}).Where("name = ?", name).
Where("id = (?)", subQuery).
Preload("Attrs").
First(&comp)

if r.Error != nil {
if errors.Is(r.Error, gorm.ErrRecordNotFound) {
return nil, ErrDBComponentDSNotExist
}
return nil, r.Error
}

return &comp, nil
}

func (db *DB) SaveComponent(comp *Component) (uint, error) {
var regIndex int
for i, attr := range comp.Attrs {
if attr.Name == "region" {
regIndex = i
}
}

_, err := db.GetComponentFromNameAttrs(comp.Name, &comp.Attrs[regIndex])
if err == nil {
return 0, ErrDBComponentExists
}

if !errors.Is(err, gorm.ErrRecordNotFound) {
return 0, err
}

r := db.g.Create(comp)

if r.Error != nil {
return 0, r.Error
}

return comp.ID, nil
}

const statusSYSTEM = "SYSTEM"

func (db *DB) MoveComponentFromOldToAnotherIncident(comp *Component, incOld, incNew *Incident) (*Incident, error) {
1 change: 1 addition & 0 deletions internal/db/errors.go
Original file line number Diff line number Diff line change
@@ -3,4 +3,5 @@ package db
import "errors"

var ErrDBComponentDSNotExist = errors.New("component does not exist")
var ErrDBComponentExists = errors.New("component exists")
var ErrDBIncidentDSNotExist = errors.New("incident does not exist")
4 changes: 2 additions & 2 deletions openapi.yaml
Original file line number Diff line number Diff line change
@@ -395,7 +395,7 @@ components:
required:
- id
- name
- attrs
- attributes
properties:
id:
type: integer
@@ -404,7 +404,7 @@ components:
name:
type: string
example: "Object Storage Service"
attrs:
attributes:
type: array
items:
$ref: '#/components/schemas/ComponentAttrV1'
99 changes: 99 additions & 0 deletions tests/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package tests

import (
"context"
"fmt"
"log"
"path/filepath"
"testing"
"time"

"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/stackmon/otc-status-dashboard/internal/api"
"github.com/stackmon/otc-status-dashboard/internal/api/errors"
v1 "github.com/stackmon/otc-status-dashboard/internal/api/v1"
"github.com/stackmon/otc-status-dashboard/internal/conf"
"github.com/stackmon/otc-status-dashboard/internal/db"
)

const (
pgImage = "postgres:15-alpine"
pgDump = "dumb_test.sql"
pgDumpDir = "testdata"

dbName = "status_dashboard"
dbUser = "pg"
dbPassword = "pass"
)

var databaseURL = "postgresql://%s:%s@localhost:%s/%s"

func TestMain(m *testing.M) {
ctx := context.Background()
container, err := postgres.Run(ctx,
pgImage,
postgres.WithInitScripts(filepath.Join(pgDumpDir, pgDump)),
postgres.WithDatabase(dbName),
postgres.WithUsername(dbUser),
postgres.WithPassword(dbPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(5*time.Second)),
)
defer func() {
if err = testcontainers.TerminateContainer(container); err != nil {
log.Printf("failed to terminate container: %s", err)
}
}()
if err != nil {
log.Printf("failed to start container: %s", err)
return
}

ports, _ := container.Ports(ctx)
port := ports["5432/tcp"][0].HostPort
databaseURL = fmt.Sprintf(databaseURL, dbUser, dbPassword, port, dbName)

m.Run()
}

func initTests(t *testing.T) (*gin.Engine, *db.DB) { //nolint:unparam
t.Helper()
t.Log("init structs")

d, err := db.New(&conf.Config{
DB: databaseURL,
})
require.NoError(t, err)

gin.SetMode(gin.TestMode)
r := gin.Default()
r.NoRoute(errors.Return404)
r.Use(api.ErrorHandle())

logger, _ := zap.NewDevelopment()
initRoutesV1(t, r, d, logger)

return r, d
}

func initRoutesV1(t *testing.T, c *gin.Engine, dbInst *db.DB, log *zap.Logger) {
t.Helper()
t.Log("init routes")

v1Api := c.Group("v1")
{
v1Api.GET("component_status", v1.GetComponentsStatusHandler(dbInst, log))
v1Api.POST("component_status", v1.PostComponentStatusHandler(dbInst, log))

v1Api.GET("incidents", v1.GetIncidentsHandler(dbInst, log))
}
}
416 changes: 416 additions & 0 deletions tests/testdata/dumb_test.sql

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions tests/v1_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package tests

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

v1 "github.com/stackmon/otc-status-dashboard/internal/api/v1"
)

func TestGetIncidentsHandler(t *testing.T) {
t.Log("start to test GET /v1/incidents")
r, _ := initTests(t)

var response = `[{"id":1,"text":"Opened incident without any update","impact":1,"start_date":"2024-10-24 10:12","end_date":null,"updates":[]}]`

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/incidents", nil)

r.ServeHTTP(w, req)

assert.Equal(t, 200, w.Code)
assert.Equal(t, response, w.Body.String())
}

func TestGetComponentsStatusHandler(t *testing.T) {
t.Log("start to test GET /v1/component_status")
r, _ := initTests(t)

var response = `[{"id":1,"attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Container"},{"name":"type","value":"cce"}],"name":"Cloud Container Engine","incidents":[{"id":1,"text":"Opened incident without any update","impact":1,"start_date":"2024-10-24 10:12","end_date":null,"updates":[]}]},{"id":2,"attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Container"},{"name":"type","value":"cce"}],"name":"Cloud Container Engine","incidents":[]},{"id":3,"attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Compute"},{"name":"type","value":"ecs"}],"name":"Elastic Cloud Server","incidents":[]},{"id":4,"attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Compute"},{"name":"type","value":"ecs"}],"name":"Elastic Cloud Server","incidents":[]},{"id":5,"attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Database"},{"name":"type","value":"dcs"}],"name":"Distributed Cache Service","incidents":[]},{"id":6,"attributes":[{"name":"region","value":"EU-NL"},{"name":"category","value":"Database"},{"name":"type","value":"dcs"}],"name":"Distributed Cache Service","incidents":[]}]`

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/component_status", nil)

r.ServeHTTP(w, req)

assert.Equal(t, 200, w.Code)
assert.Equal(t, response, w.Body.String())
}

func TestPostComponentsStatusHandler(t *testing.T) {
t.Log("start to test POST requests to /v1/component_status")
r, _ := initTests(t)

type testCase struct {
ExpectedCode int
Expected string
JSON string
}

testCases := map[string]*testCase{
"positive testcase": {
JSON: `{"name":"Distributed Cache Service","text":"Incident","impact": 2,"attributes": [{"name":"region","value":"EU-NL"}]}`,
Expected: `{"id":2,"text":"Incident","impact":2,`,
ExpectedCode: 200,
},
"negative testcase, region is invalid": {
JSON: `{"name":"Distributed Cache Service","text":"Incident","impact": 2,"attributes": [{"name":"region","value":"EU-NL123"}]}`,
Expected: `{"errMsg":"component does not exist"}`,
ExpectedCode: 400,
},
"negative testcase, attribute region is missing": {
JSON: `{"name":"Distributed Cache Service","text":"Incident","impact": 2,"attributes": [{"name":"type","value":"dcs"}]}`,
Expected: `{"errMsg":"component attribute region is missing or invalid"}`,
ExpectedCode: 400,
},
"negative testcase, component name is wrong": {
JSON: `{"name":"New Distributed Cache Service","text":"Incident","impact": 2,"attributes": [{"name":"region","value":"EU-NL"}]}`,
Expected: `{"errMsg":"component does not exist"}`,
ExpectedCode: 400,
},
//nolint:gocritic
//"negative testcase, the incident with given impact and component already exists": {
// JSON: `{"name":"Cloud Container Engine","text":"Incident","impact": 1,"attributes": [{"name":"region","value":"EU-DE"}]}`,
// Expected: `{"details":"Check your request parameters","existingIncidentId":1,"existingIncidentTitle":"Opened incident without any update","message":"Incident with this the component already exists","targetComponent":{"id":1,"name":"Cloud Container Engine","attributes":[{"name":"region","value":"EU-DE"},{"name":"category","value":"Container"},{"name":"type","value":"cce"}]}}`,
//},
}

for title, c := range testCases {
t.Logf("start test case: %s\n", title)

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/v1/component_status", strings.NewReader(c.JSON))
r.ServeHTTP(w, req)

if title == "positive testcase" {
inc := &v1.Incident{}
err := json.Unmarshal(w.Body.Bytes(), inc)
require.NoError(t, err)
assert.Equal(t, 2, inc.ID)
assert.Equal(t, "Incident", inc.Text)
assert.Equal(t, 0, len(inc.Updates)) //nolint:testifylint
assert.Equal(t, http.StatusOK, c.ExpectedCode)
assert.True(t, strings.HasPrefix(w.Body.String(), c.Expected))
continue
}
assert.Equal(t, c.ExpectedCode, w.Code)
assert.Equal(t, c.Expected, w.Body.String())
}
}