diff --git a/Taskfile.yml b/Taskfile.yml index 25dd942..9ad24e3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -61,6 +61,22 @@ tasks: SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable cmd: go run main.go migrate --migrations-path=file://migrations + force-migration: + desc: Force migrate to a desired version + vars: + SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable + cmd: "{{ .MIGRATE }} -path=./migrations -database={{ .SOFTPLAYER_DB_CONNECTION_STRING }} force {{ .CLI_ARGS }}" + deps: + - migrate + + drop-migrations: + desc: Drop migrations + vars: + SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable + cmd: "{{ .MIGRATE }} -path=./migrations -database={{ .SOFTPLAYER_DB_CONNECTION_STRING }} drop" + deps: + - migrate + run-server-dev: desc: Run the local dev server deps: @@ -113,6 +129,8 @@ tasks: desc: Add a new database migration silent: true cmd: "{{.MIGRATE}} create -dir migrations -ext sql {{.CLI_ARGS}}" + deps: + - migrate # Install required tools localbin: @@ -132,6 +150,7 @@ tasks: TARGET: "{{.MIGRATE}}" PACKAGE: github.com/golang-migrate/migrate/v4/cmd/migrate VERSION: latest + TAGS: "postgres" go-install-tool: internal: true @@ -150,7 +169,13 @@ tasks: echo "Downloading $PACKAGE" rm -f "$TARGET" - GOBIN="{{.LOCALBIN}}" go install "$PACKAGE" + TAGS="{{.TAGS}}" + if [ -n "$TAGS" ]; then + echo "Using build tags: $TAGS" + GOBIN="{{.LOCALBIN}}" go install -tags "$TAGS" "$PACKAGE" + else + GOBIN="{{.LOCALBIN}}" go install "$PACKAGE" + fi mv "{{.LOCALBIN}}/$(basename "$TARGET")" "$VERSIONED" ln -sf "$(realpath "$VERSIONED")" "$TARGET" diff --git a/internal/repository/accounts.go b/internal/repository/accounts.go index b966e7c..77e85e5 100644 --- a/internal/repository/accounts.go +++ b/internal/repository/accounts.go @@ -10,8 +10,9 @@ import ( ) var ( - ErrAlreadyExists = errors.New("entry already exists") - ErrNotFound = errors.New("entry not found") + ErrAlreadyExists = errors.New("entry already exists") + ErrCheckNotPassed = errors.New("sql checks not passed") + ErrNotFound = errors.New("entry not found") ) type AccountData struct { diff --git a/internal/repository/projects.go b/internal/repository/projects.go index 4de7726..4022b90 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -3,7 +3,11 @@ package repository import ( "context" "database/sql" + "errors" "time" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5/pgconn" ) type ProjectData struct { @@ -13,7 +17,7 @@ type ProjectData struct { Description string CreatedBy string CreatedAt time.Time - ArchivedAt sql.NullTime + ClosedAt sql.NullTime Blocked bool UpdatedAt time.Time UpdatedBy string @@ -23,18 +27,67 @@ type ProjectData struct { func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { query := ` INSERT INTO projects - (uuid, name, slug, ) + (uuid, name, slug, description, owner_user_id, billing_account_id, created_by, created_at, updated_by, updated_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ` + + if _, err := db.ExecContext(ctx, query, + data.UUID, data.Name, data.Slug, data.Description, data.CreatedBy, + data.CreatedBy, data.CreatedBy, data.CreatedAt, data.CreatedBy, data.CreatedAt); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case pgerrcode.UniqueViolation: + return ErrAlreadyExists + case pgerrcode.CheckViolation: + return ErrCheckNotPassed + } + return err + } + return err + } return nil } // GetProjectByID returns a project from the database -func GetProjectByID(ctx context.Context, db *sql.DB, projectID string) (data *ProjectData, err error) { - return nil, nil +func GetProjectByID(ctx context.Context, db *sql.DB, projectID string) (*ProjectData, error) { + data := &ProjectData{} + query := ` + SELECT uuid, name, slug, description, owner_user_id, closed_at, created_at + FROM projects + WHERE uuid=$1` + + if err := db.QueryRowContext(ctx, query, projectID).Scan( + &data.UUID, + &data.Name, + &data.Slug, + &data.Description, + &data.CreatedBy, + &data.ClosedAt, + &data.CreatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return data, nil } // UpdateProject change editable project data func UpdateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { + query := ` + UPDATE projects + SET + name = $2, + description = $3, + updated_at = $4, + updated_by = $5 + WHERE uuid = $1;` + if _, err := db.Query(query, data.UUID, data.Name, data.Description, data.UpdatedAt, data.UpdatedBy); err != nil { + return err + } return nil } diff --git a/internal/repository/projects_test.go b/internal/repository/projects_test.go index 534f39f..7c5b4eb 100644 --- a/internal/repository/projects_test.go +++ b/internal/repository/projects_test.go @@ -7,20 +7,127 @@ import ( "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository" "github.com/google/uuid" + "github.com/stretchr/testify/assert" ) -func TestProjectsRepository_Success(t *testing.T) { +func TestIntegrationProjectsCreate_Success(t *testing.T) { createdBy := uuid.NewString() - _ = &repository.ProjectData{ + project := &repository.ProjectData{ UUID: uuid.NewString(), Name: "test-1", - Slug: "test_1", + Slug: uuid.NewString(), Description: "Test Project Number 1", CreatedBy: createdBy, CreatedAt: time.Now(), - ArchivedAt: sql.NullTime{Time: time.Now()}, + ClosedAt: sql.NullTime{Time: time.Now()}, Blocked: false, UpdatedAt: time.Now(), UpdatedBy: createdBy, } + + assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project)) +} + +func TestIntegrationProjectsCreate_CheckFailed(t *testing.T) { + createdBy := uuid.NewString() + project := &repository.ProjectData{ + UUID: uuid.NewString(), + Name: "test-2", + Slug: "test_2", + Description: "Test Project Number 1", + CreatedBy: createdBy, + CreatedAt: time.Now(), + ClosedAt: sql.NullTime{Time: time.Now()}, + Blocked: false, + UpdatedAt: time.Now(), + UpdatedBy: createdBy, + } + assert.Error(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project), repository.ErrCheckNotPassed) +} + +func TestIntegrationProjectsCreate_AlreadyExistsFail(t *testing.T) { + createdBy := uuid.NewString() + project := &repository.ProjectData{ + UUID: uuid.NewString(), + Name: "test-3", + Slug: uuid.NewString(), + Description: "Test Project Number 1", + CreatedBy: createdBy, + CreatedAt: time.Now(), + ClosedAt: sql.NullTime{Time: time.Now()}, + Blocked: false, + UpdatedAt: time.Now(), + UpdatedBy: createdBy, + } + + assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project)) + assert.Error(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project), repository.ErrAlreadyExists) +} + +func TestIntegrationProjectsCreateAndGet_Success(t *testing.T) { + createdBy := uuid.NewString() + project := &repository.ProjectData{ + UUID: uuid.NewString(), + Name: "test-4", + Slug: uuid.NewString(), + Description: "Test Project Number 1", + CreatedBy: createdBy, + CreatedAt: time.Now(), + Blocked: false, + UpdatedAt: time.Now(), + UpdatedBy: createdBy, + } + + assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project)) + data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID) + assert.NoError(t, err) + assert.Equal(t, project.Name, data.Name) + assert.Equal(t, project.Slug, data.Slug) + assert.Equal(t, project.Description, data.Description) + assert.Equal(t, project.CreatedBy, data.CreatedBy) + assert.Equal(t, project.ClosedAt.Time.Truncate(time.Second), data.ClosedAt.Time.Truncate(time.Second)) + assert.Equal(t, project.CreatedAt.Truncate(time.Second), data.CreatedAt.Truncate(time.Second)) +} + +func TestIntegrationProjectsCreateAndGet_NotFound(t *testing.T) { + data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), uuid.NewString()) + assert.ErrorIs(t, err, repository.ErrNotFound) + assert.Nil(t, data) +} + +func TestIntegrationProjectsCreateUpdateAndGet_Success(t *testing.T) { + createdBy := uuid.NewString() + project := &repository.ProjectData{ + UUID: uuid.NewString(), + Name: "test-5", + Slug: uuid.NewString(), + Description: "Test Project Number 1", + CreatedBy: createdBy, + CreatedAt: time.Now(), + Blocked: false, + UpdatedAt: time.Now(), + UpdatedBy: createdBy, + } + + assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project)) + data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID) + assert.NoError(t, err) + assert.Equal(t, project.Name, data.Name) + assert.Equal(t, project.Slug, data.Slug) + assert.Equal(t, project.Description, data.Description) + assert.Equal(t, project.CreatedBy, data.CreatedBy) + assert.Equal(t, project.ClosedAt.Time.Truncate(time.Second), data.ClosedAt.Time.Truncate(time.Second)) + assert.Equal(t, project.CreatedAt.Truncate(time.Second), data.CreatedAt.Truncate(time.Second)) + + project.UpdatedBy = uuid.NewString() + project.UpdatedAt = time.Now() + project.Description = "Updated description" + project.Name = "update name" + + assert.NoError(t, repository.UpdateProject(t.Context(), newTestDBConnection(t.Context()), project)) + data, err = repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID) + assert.NoError(t, err) + assert.Equal(t, project.Name, data.Name) + assert.Equal(t, project.Slug, data.Slug) + assert.Equal(t, project.Description, data.Description) } diff --git a/migrations/20260518193048_projects_init.up.sql b/migrations/20260518193048_projects_init.up.sql index 3a670b1..49d7783 100644 --- a/migrations/20260518193048_projects_init.up.sql +++ b/migrations/20260518193048_projects_init.up.sql @@ -1,5 +1,5 @@ CREATE TABLE projects ( - id UUID PRIMARY KEY, + uuid UUID PRIMARY KEY, name VARCHAR(120) NOT NULL, slug VARCHAR(120) NOT NULL UNIQUE CHECK ( @@ -7,11 +7,10 @@ CREATE TABLE projects ( ), description TEXT, owner_user_id UUID NOT NULL, - archived_at TIMESTAMP NULL, - closed_at TIMESTAMP NULL, + closed_at TIMESTAMPTZ NULL, billing_account_id UUID NULL, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL, - updated_by UUID NOT NULL, + updated_by UUID NOT NULL );