NOISSUE - Add Invitation service repository layer (#120)

* feat(invitations): add repository layer

Add PostgreSQL migration, setup test, and
repository for invitations

Signed-off-by: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com>

* feat(invitations): Implement error handling and repository functions

This commit introduces modifications to the error handling and repository functions in the internal/postgres and invitations/postgres packages. The changes primarily focus on retrieving invitations from a database and handling errors appropriately. Additionally, the code diff includes updates to imports and test cases for the InvitationCreate function. The Retrieve function is thoroughly tested with different scenarios, including tests for updating invitation tokens and confirmations. Moreover, tests are added for the update and delete functions for invitations in a repository.

Signed-off-by: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com>

* feat: test with token

Signed-off-by: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com>

* feat(Page): Add new field `InvitedByOrUserID`

This code change adds a new field to the Page struct and modifies the pageQuery function to handle it. The change also includes modifications to the test file. The code snippet tests the functionality of retrieving invitations, specifically using the invited_by_or_user_id field. The test ensures that the retrieved invitations match the expected response.

Signed-off-by: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com>

* feat(invitations): add updated_at field to UpdateConfirmation query

This commit adds the updated_at field to the UpdateConfirmation query in the invitations package. This ensures that the updated_at field is updated when confirming an invitation.

Signed-off-by: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com>

---------

Signed-off-by: Rodney Osodo <28790446+rodneyosodo@users.noreply.github.com>
This commit is contained in:
b1ackd0t
2023-12-07 18:11:54 +03:00
committed by GitHub
parent c8126d1a03
commit 1f087bf42c
9 changed files with 1214 additions and 12 deletions
+6 -5
View File
@@ -4,7 +4,8 @@
package postgres
import (
"github.com/absmach/magistrala/pkg/errors"
"errors"
repoerror "github.com/absmach/magistrala/pkg/errors/repository"
"github.com/jackc/pgx/v5/pgconn"
)
@@ -23,13 +24,13 @@ func HandleError(wrapper, err error) error {
if ok {
switch pqErr.Code {
case errDuplicate:
return errors.Wrap(repoerror.ErrConflict, err)
return errors.Join(repoerror.ErrConflict, err)
case errInvalid, errTruncation:
return errors.Wrap(repoerror.ErrMalformedEntity, err)
return errors.Join(repoerror.ErrMalformedEntity, err)
case errFK:
return errors.Wrap(repoerror.ErrCreateEntity, err)
return errors.Join(repoerror.ErrCreateEntity, err)
}
}
return errors.Wrap(wrapper, err)
return errors.Join(wrapper, err)
}
+11 -7
View File
@@ -31,12 +31,13 @@ type Invitation struct {
// Page is a page of invitations.
type Page struct {
Offset uint64 `json:"offset" db:"offset"`
Limit uint64 `json:"limit" db:"limit"`
InvitedBy string `json:"invited_by,omitempty" db:"invited_by,omitempty"`
UserID string `json:"user_id,omitempty" db:"user_id,omitempty"`
Domain string `json:"domain,omitempty" db:"domain,omitempty"`
Relation string `json:"relation,omitempty" db:"relation,omitempty"`
Offset uint64 `json:"offset" db:"offset"`
Limit uint64 `json:"limit" db:"limit"`
InvitedBy string `json:"invited_by,omitempty" db:"invited_by,omitempty"`
UserID string `json:"user_id,omitempty" db:"user_id,omitempty"`
Domain string `json:"domain,omitempty" db:"domain,omitempty"`
Relation string `json:"relation,omitempty" db:"relation,omitempty"`
InvitedByOrUserID string `db:"invited_by_or_user_id,omitempty"`
}
// InvitationPage is a page of invitations.
@@ -84,6 +85,9 @@ type Repository interface {
// Create creates an invitation.
Create(ctx context.Context, invitation Invitation) (err error)
// Retrieve returns an invitation.
Retrieve(ctx context.Context, userID, domainID string) (Invitation, error)
// RetrieveAll returns a list of invitations based on the given page.
RetrieveAll(ctx context.Context, withToken bool, page Page) (invitations InvitationPage, err error)
@@ -94,7 +98,7 @@ type Repository interface {
UpdateConfirmation(ctx context.Context, invitation Invitation) (err error)
// Delete deletes an invitation.
Delete(ctx context.Context, userID, domain string) (err error)
Delete(ctx context.Context, userID, domainID string) (err error)
}
// CheckRelation checks if the given relation is valid.
+5
View File
@@ -0,0 +1,5 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package mocks provides a mock implementation of the invitations repository.
package mocks
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package mocks
import (
"context"
"github.com/absmach/magistrala/invitations"
"github.com/absmach/magistrala/pkg/errors"
"github.com/stretchr/testify/mock"
)
const Invalid = "invalid"
var _ invitations.Repository = (*Repository)(nil)
type Repository struct {
mock.Mock
}
func (m *Repository) Create(ctx context.Context, invitation invitations.Invitation) error {
ret := m.Called(ctx, invitation)
if invitation.UserID == Invalid || invitation.Domain == Invalid || invitation.InvitedBy == Invalid {
return errors.ErrNotFound
}
return ret.Error(0)
}
func (m *Repository) Retrieve(ctx context.Context, userID, domainID string) (invitations.Invitation, error) {
ret := m.Called(ctx, userID, domainID)
if userID == Invalid || domainID == Invalid {
return invitations.Invitation{}, errors.ErrNotFound
}
return ret.Get(0).(invitations.Invitation), ret.Error(1)
}
func (m *Repository) RetrieveAll(ctx context.Context, withToken bool, page invitations.Page) (invitations.InvitationPage, error) {
ret := m.Called(ctx, page)
if page.UserID == Invalid || page.Domain == Invalid {
return invitations.InvitationPage{}, errors.ErrNotFound
}
return ret.Get(0).(invitations.InvitationPage), ret.Error(1)
}
func (m *Repository) UpdateToken(ctx context.Context, invitation invitations.Invitation) error {
ret := m.Called(ctx, invitation)
if invitation.UserID == Invalid || invitation.Domain == Invalid {
return errors.ErrNotFound
}
return ret.Error(0)
}
func (m *Repository) UpdateConfirmation(ctx context.Context, invitation invitations.Invitation) error {
ret := m.Called(ctx, invitation)
if invitation.UserID == Invalid || invitation.Domain == Invalid {
return errors.ErrNotFound
}
return ret.Error(0)
}
func (m *Repository) Delete(ctx context.Context, userID, domain string) error {
ret := m.Called(ctx, userID, domain)
if userID == Invalid || domain == Invalid {
return errors.ErrNotFound
}
return ret.Error(0)
}
+5
View File
@@ -0,0 +1,5 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package postgres provides a postgres implementation of the invitations repository.
package postgres
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres
import (
_ "github.com/jackc/pgx/v5/stdlib" // required for SQL access
migrate "github.com/rubenv/sql-migrate"
)
func Migration() *migrate.MemoryMigrationSource {
return &migrate.MemoryMigrationSource{
Migrations: []*migrate.Migration{
{
Id: "invitations_01",
// VARCHAR(36) for colums with IDs as UUIDS have a maximum of 36 characters
Up: []string{
`CREATE TABLE IF NOT EXISTS invitations (
invited_by VARCHAR(36) NOT NULL,
user_id VARCHAR(36) NOT NULL,
domain VARCHAR(36) NOT NULL,
token TEXT NOT NULL,
relation VARCHAR(254) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP,
confirmed_at TIMESTAMP,
UNIQUE (user_id, domain),
PRIMARY KEY (user_id, domain)
)`,
},
Down: []string{
`DROP TABLE IF EXISTS invitations`,
},
},
},
}
}
+168
View File
@@ -0,0 +1,168 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres
import (
"context"
"fmt"
"strings"
"github.com/absmach/magistrala/internal/postgres"
"github.com/absmach/magistrala/invitations"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
)
type repository struct {
db postgres.Database
}
func NewRepository(db postgres.Database) invitations.Repository {
return &repository{db: db}
}
func (repo *repository) Create(ctx context.Context, invitation invitations.Invitation) (err error) {
q := `INSERT INTO invitations (invited_by, user_id, domain, token, relation, created_at, updated_at, confirmed_at)
VALUES (:invited_by, :user_id, :domain, :token, :relation, :created_at, :updated_at, :confirmed_at)`
if _, err = repo.db.NamedExecContext(ctx, q, invitation); err != nil {
return postgres.HandleError(repoerr.ErrCreateEntity, err)
}
return nil
}
func (repo *repository) Retrieve(ctx context.Context, userID, domainID string) (invitations.Invitation, error) {
q := `SELECT invited_by, user_id, domain, relation, created_at, updated_at, confirmed_at FROM invitations WHERE user_id = :user_id AND domain = :domain`
inv := invitations.Invitation{
UserID: userID,
Domain: domainID,
}
rows, err := repo.db.NamedQueryContext(ctx, q, inv)
if err != nil {
return invitations.Invitation{}, postgres.HandleError(repoerr.ErrViewEntity, err)
}
defer rows.Close()
var item invitations.Invitation
if rows.Next() {
if err = rows.StructScan(&item); err != nil {
return invitations.Invitation{}, postgres.HandleError(repoerr.ErrViewEntity, err)
}
return item, nil
}
return invitations.Invitation{}, repoerr.ErrNotFound
}
func (repo *repository) RetrieveAll(ctx context.Context, withToken bool, page invitations.Page) (invitations.InvitationPage, error) {
query := pageQuery(page)
queryColumns := "invited_by, user_id, domain, relation, created_at, updated_at, confirmed_at"
if withToken {
queryColumns += ", token"
}
q := fmt.Sprintf("SELECT %s FROM invitations %s LIMIT :limit OFFSET :offset", queryColumns, query)
rows, err := repo.db.NamedQueryContext(ctx, q, page)
if err != nil {
return invitations.InvitationPage{}, postgres.HandleError(repoerr.ErrViewEntity, err)
}
defer rows.Close()
var items []invitations.Invitation
for rows.Next() {
var item invitations.Invitation
if err = rows.StructScan(&item); err != nil {
return invitations.InvitationPage{}, postgres.HandleError(repoerr.ErrViewEntity, err)
}
items = append(items, item)
}
tq := fmt.Sprintf(`SELECT COUNT(*) FROM invitations %s`, query)
total, err := postgres.Total(ctx, repo.db, tq, page)
if err != nil {
return invitations.InvitationPage{}, postgres.HandleError(repoerr.ErrViewEntity, err)
}
invPage := invitations.InvitationPage{
Total: total,
Offset: page.Offset,
Limit: page.Limit,
Invitations: items,
}
return invPage, nil
}
func (repo *repository) UpdateToken(ctx context.Context, invitation invitations.Invitation) (err error) {
q := `UPDATE invitations SET token = :token, updated_at = :updated_at WHERE user_id = :user_id AND domain = :domain`
result, err := repo.db.NamedExecContext(ctx, q, invitation)
if err != nil {
return postgres.HandleError(repoerr.ErrUpdateEntity, err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return repoerr.ErrNotFound
}
return nil
}
func (repo *repository) UpdateConfirmation(ctx context.Context, invitation invitations.Invitation) (err error) {
q := `UPDATE invitations SET confirmed_at = :confirmed_at, updated_at = :updated_at WHERE user_id = :user_id AND domain = :domain`
result, err := repo.db.NamedExecContext(ctx, q, invitation)
if err != nil {
return postgres.HandleError(repoerr.ErrUpdateEntity, err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return repoerr.ErrNotFound
}
return nil
}
func (repo *repository) Delete(ctx context.Context, userID, domain string) (err error) {
q := `DELETE FROM invitations WHERE user_id = $1 AND domain = $2`
result, err := repo.db.ExecContext(ctx, q, userID, domain)
if err != nil {
return postgres.HandleError(repoerr.ErrRemoveEntity, err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return repoerr.ErrNotFound
}
return nil
}
func pageQuery(pm invitations.Page) string {
var query []string
var emq string
if pm.Domain != "" {
query = append(query, "domain = :domain")
}
if pm.UserID != "" {
query = append(query, "user_id = :user_id")
}
if pm.InvitedBy != "" {
query = append(query, "invited_by = :invited_by")
}
if pm.Relation != "" {
query = append(query, "relation = :relation")
}
if pm.InvitedByOrUserID != "" {
query = append(query, "(invited_by = :invited_by_or_user_id OR user_id = :invited_by_or_user_id)")
}
if len(query) > 0 {
emq = fmt.Sprintf("WHERE %s", strings.Join(query, " AND "))
}
return emq
}
+805
View File
@@ -0,0 +1,805 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/absmach/magistrala/invitations"
"github.com/absmach/magistrala/invitations/postgres"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
invalidUUID = strings.Repeat("a", 37)
validToken = strings.Repeat("a", 1024)
relation = "relation"
)
func TestInvitationCreate(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM invitations")
require.Nil(t, err, fmt.Sprintf("clean invitations unexpected error: %s", err))
})
repo := postgres.NewRepository(database)
domain := generateUUID(t)
userID := generateUUID(t)
cases := []struct {
desc string
invitation invitations.Invitation
err error
}{
{
desc: "add new invitation successfully",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: userID,
Domain: domain,
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
},
err: nil,
},
{
desc: "add new invitation with an confirmed_at date",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
ConfirmedAt: time.Now(),
},
err: nil,
},
{
desc: "add invitation with duplicate invitation",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: userID,
Domain: domain,
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
},
err: repoerr.ErrConflict,
},
{
desc: "add invitation with invalid invitation invited_by",
invitation: invitations.Invitation{
InvitedBy: invalidUUID,
UserID: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
},
err: repoerr.ErrMalformedEntity,
},
{
desc: "add invitation with invalid invitation relation",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
Relation: strings.Repeat("a", 255),
CreatedAt: time.Now(),
},
err: repoerr.ErrMalformedEntity,
},
{
desc: "add invitation with invalid invitation domain",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Domain: invalidUUID,
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
},
err: repoerr.ErrMalformedEntity,
},
{
desc: "add invitation with invalid invitation user id",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: invalidUUID,
Domain: generateUUID(t),
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
},
err: repoerr.ErrMalformedEntity,
},
{
desc: "add invitation with empty invitation domain",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
},
err: nil,
},
{
desc: "add invitation with empty invitation user id",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
},
err: nil,
},
{
desc: "add invitation with empty invitation invited_by",
invitation: invitations.Invitation{
Domain: generateUUID(t),
UserID: generateUUID(t),
Token: validToken,
Relation: relation,
CreatedAt: time.Now(),
},
err: nil,
},
{
desc: "add invitation with empty invitation token",
invitation: invitations.Invitation{
InvitedBy: generateUUID(t),
Domain: generateUUID(t),
UserID: generateUUID(t),
Relation: relation,
CreatedAt: time.Now(),
},
err: nil,
},
}
for _, tc := range cases {
switch err := repo.Create(context.Background(), tc.invitation); {
case err == nil:
assert.Nil(t, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
default:
assert.ErrorIs(t, err, tc.err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
}
func TestInvitationRetrieve(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM invitations")
require.Nil(t, err, fmt.Sprintf("clean invitations unexpected error: %s", err))
})
repo := postgres.NewRepository(database)
invitation := invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
Relation: relation,
CreatedAt: time.Now().UTC().Truncate(time.Microsecond),
}
err := repo.Create(context.Background(), invitation)
require.Nil(t, err, fmt.Sprintf("create invitation unexpected error: %s", err))
invitation.Token = ""
cases := []struct {
desc string
userID string
domainID string
response invitations.Invitation
err error
}{
{
desc: "retrieve invitations successfully",
userID: invitation.UserID,
domainID: invitation.Domain,
response: invitation,
err: nil,
},
{
desc: "retrieve invitations with invalid invitation user id",
userID: generateUUID(t),
domainID: invitation.Domain,
response: invitations.Invitation{},
err: repoerr.ErrNotFound,
},
{
desc: "retrieve invitations with invalid invitation domain",
userID: invitation.UserID,
domainID: generateUUID(t),
response: invitations.Invitation{},
err: repoerr.ErrNotFound,
},
{
desc: "retrieve invitations with invalid invitation user id and domain",
userID: generateUUID(t),
domainID: generateUUID(t),
response: invitations.Invitation{},
err: repoerr.ErrNotFound,
},
{
desc: "retrieve invitations with empty invitation user id",
userID: "",
domainID: invitation.Domain,
response: invitations.Invitation{},
err: repoerr.ErrNotFound,
},
{
desc: "retrieve invitations with empty invitation domain",
userID: invitation.UserID,
domainID: "",
response: invitations.Invitation{},
err: repoerr.ErrNotFound,
},
{
desc: "retrieve invitations with empty invitation user id and domain",
userID: "",
domainID: "",
response: invitations.Invitation{},
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
page, err := repo.Retrieve(context.Background(), tc.userID, tc.domainID)
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
assert.Equal(t, tc.response, page, fmt.Sprintf("desc: %s\n", tc.desc))
}
}
func TestInvitationRetrieveAll(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM invitations")
require.Nil(t, err, fmt.Sprintf("clean invitations unexpected error: %s", err))
})
repo := postgres.NewRepository(database)
num := 200
var items []invitations.Invitation
for i := 0; i < num; i++ {
invitation := invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
Relation: fmt.Sprintf("%s-%d", relation, i),
CreatedAt: time.Now().UTC().Truncate(time.Microsecond),
}
err := repo.Create(context.Background(), invitation)
require.Nil(t, err, fmt.Sprintf("create invitation unexpected error: %s", err))
invitation.Token = ""
items = append(items, invitation)
}
cases := []struct {
desc string
page invitations.Page
withToken bool
response invitations.InvitationPage
err error
}{
{
desc: "retrieve invitations successfully",
page: invitations.Page{
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 0,
Limit: 10,
Invitations: items[:10],
},
err: nil,
},
{
desc: "retrieve invitations with token",
page: invitations.Page{
Offset: 0,
Limit: 10,
},
withToken: true,
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 0,
Limit: 10,
Invitations: addToken(items[:10]),
},
err: nil,
},
{
desc: "retrieve invitations with offset",
page: invitations.Page{
Offset: 10,
Limit: 10,
},
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 10,
Limit: 10,
Invitations: items[10:20],
},
},
{
desc: "retrieve invitations with limit",
page: invitations.Page{
Offset: 0,
Limit: 50,
},
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 0,
Limit: 50,
Invitations: items[:50],
},
},
{
desc: "retrieve invitations with offset and limit",
page: invitations.Page{
Offset: 10,
Limit: 50,
},
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 10,
Limit: 50,
Invitations: items[10:60],
},
},
{
desc: "retrieve invitations with offset out of range",
page: invitations.Page{
Offset: 1000,
Limit: 50,
},
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 1000,
Limit: 50,
Invitations: []invitations.Invitation(nil),
},
},
{
desc: "retrieve invitations with offset and limit out of range",
page: invitations.Page{
Offset: 170,
Limit: 50,
},
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 170,
Limit: 50,
Invitations: items[170:200],
},
},
{
desc: "retrieve invitations with limit out of range",
page: invitations.Page{
Offset: 0,
Limit: 1000,
},
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 0,
Limit: 1000,
Invitations: items,
},
},
{
desc: "retrieve invitations with empty page",
page: invitations.Page{},
response: invitations.InvitationPage{
Total: uint64(num),
Offset: 0,
Limit: 0,
Invitations: []invitations.Invitation(nil),
},
},
{
desc: "retrieve invitations with domain",
page: invitations.Page{
Domain: items[0].Domain,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with user id",
page: invitations.Page{
UserID: items[0].UserID,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with invited_by",
page: invitations.Page{
InvitedBy: items[0].InvitedBy,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with invited_by_or_user_id",
page: invitations.Page{
InvitedByOrUserID: items[0].UserID,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with relation",
page: invitations.Page{
Relation: relation + "-0",
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with domain and user id",
page: invitations.Page{
Domain: items[0].Domain,
UserID: items[0].UserID,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with domain and invited_by",
page: invitations.Page{
Domain: items[0].Domain,
InvitedBy: items[0].InvitedBy,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with user id and invited_by",
page: invitations.Page{
UserID: items[0].UserID,
InvitedBy: items[0].InvitedBy,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with domain, user id and invited_by",
page: invitations.Page{
Domain: items[0].Domain,
UserID: items[0].UserID,
InvitedBy: items[0].InvitedBy,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with domain, user id, invited_by and relation",
page: invitations.Page{
Domain: items[0].Domain,
UserID: items[0].UserID,
InvitedBy: items[0].InvitedBy,
Relation: relation + "-0",
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 1,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation{items[0]},
},
},
{
desc: "retrieve invitations with invalid domain",
page: invitations.Page{
Domain: invalidUUID,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 0,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation(nil),
},
},
{
desc: "retrieve invitations with invalid user id",
page: invitations.Page{
UserID: generateUUID(t),
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 0,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation(nil),
},
},
{
desc: "retrieve invitations with invalid invited_by",
page: invitations.Page{
InvitedBy: invalidUUID,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 0,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation(nil),
},
},
{
desc: "retrieve invitations with invalid relation",
page: invitations.Page{
Relation: invalidUUID,
Offset: 0,
Limit: 10,
},
response: invitations.InvitationPage{
Total: 0,
Offset: 0,
Limit: 10,
Invitations: []invitations.Invitation(nil),
},
},
}
for _, tc := range cases {
page, err := repo.RetrieveAll(context.Background(), tc.withToken, tc.page)
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
assert.Equal(t, tc.response, page, fmt.Sprintf("desc: %s\n", tc.desc))
}
}
func TestInvitationUpdateToken(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM invitations")
require.Nil(t, err, fmt.Sprintf("clean invitations unexpected error: %s", err))
})
repo := postgres.NewRepository(database)
invitation := invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
CreatedAt: time.Now(),
}
err := repo.Create(context.Background(), invitation)
require.Nil(t, err, fmt.Sprintf("create invitation unexpected error: %s", err))
cases := []struct {
desc string
invitation invitations.Invitation
err error
}{
{
desc: "update invitation successfully",
invitation: invitations.Invitation{
Domain: invitation.Domain,
UserID: invitation.UserID,
Token: validToken,
UpdatedAt: time.Now(),
},
err: nil,
},
{
desc: "update invitation with invalid user id",
invitation: invitations.Invitation{
UserID: generateUUID(t),
Domain: invitation.Domain,
Token: validToken,
UpdatedAt: time.Now(),
},
err: repoerr.ErrNotFound,
},
{
desc: "update invitation with invalid domain",
invitation: invitations.Invitation{
UserID: invitation.UserID,
Domain: generateUUID(t),
Token: validToken,
UpdatedAt: time.Now(),
},
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
err := repo.UpdateToken(context.Background(), tc.invitation)
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestInvitationUpdateConfirmation(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM invitations")
require.Nil(t, err, fmt.Sprintf("clean invitations unexpected error: %s", err))
})
repo := postgres.NewRepository(database)
invitation := invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
CreatedAt: time.Now(),
}
err := repo.Create(context.Background(), invitation)
require.Nil(t, err, fmt.Sprintf("create invitation unexpected error: %s", err))
cases := []struct {
desc string
invitation invitations.Invitation
err error
}{
{
desc: "update invitation successfully",
invitation: invitations.Invitation{
Domain: invitation.Domain,
UserID: invitation.UserID,
ConfirmedAt: time.Now(),
},
err: nil,
},
{
desc: "update invitation with invalid user id",
invitation: invitations.Invitation{
UserID: generateUUID(t),
Domain: invitation.UserID,
ConfirmedAt: time.Now(),
},
err: repoerr.ErrNotFound,
},
{
desc: "update invitation with invalid domain",
invitation: invitations.Invitation{
UserID: invitation.UserID,
Domain: generateUUID(t),
ConfirmedAt: time.Now(),
},
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
err := repo.UpdateConfirmation(context.Background(), tc.invitation)
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func TestInvitationDelete(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM invitations")
require.Nil(t, err, fmt.Sprintf("clean invitations unexpected error: %s", err))
})
repo := postgres.NewRepository(database)
invitation := invitations.Invitation{
InvitedBy: generateUUID(t),
UserID: generateUUID(t),
Domain: generateUUID(t),
Token: validToken,
CreatedAt: time.Now(),
}
err := repo.Create(context.Background(), invitation)
require.Nil(t, err, fmt.Sprintf("create invitation unexpected error: %s", err))
cases := []struct {
desc string
invitation invitations.Invitation
err error
}{
{
desc: "delete invitation successfully",
invitation: invitations.Invitation{
UserID: invitation.UserID,
Domain: invitation.Domain,
},
err: nil,
},
{
desc: "delete invitation with invalid invitation id",
invitation: invitations.Invitation{
UserID: generateUUID(t),
Domain: generateUUID(t),
},
err: repoerr.ErrNotFound,
},
{
desc: "delete invitation with empty invitation id",
invitation: invitations.Invitation{},
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
err := repo.Delete(context.Background(), tc.invitation.UserID, tc.invitation.Domain)
assert.Equal(t, tc.err, err, fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
}
}
func generateUUID(t *testing.T) string {
idProvider := uuid.New()
ulid, err := idProvider.ID()
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
return ulid
}
func addToken(invs []invitations.Invitation) []invitations.Invitation {
invscopy := make([]invitations.Invitation, len(invs))
copy(invscopy, invs)
for i := range invscopy {
invscopy[i].Token = validToken
}
return invscopy
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres_test
import (
"database/sql"
"fmt"
"log"
"os"
"testing"
"time"
pgClient "github.com/absmach/magistrala/internal/clients/postgres"
"github.com/absmach/magistrala/internal/postgres"
ipostgres "github.com/absmach/magistrala/invitations/postgres"
"github.com/jmoiron/sqlx"
dockertest "github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"go.opentelemetry.io/otel"
)
var (
db *sqlx.DB
database postgres.Database
tracer = otel.Tracer("repo_tests")
)
func TestMain(m *testing.M) {
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}
container, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "postgres",
Tag: "15.1-alpine",
Env: []string{
"POSTGRES_USER=test",
"POSTGRES_PASSWORD=test",
"POSTGRES_DB=test",
"listen_addresses = '*'",
},
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
if err != nil {
log.Fatalf("Could not start container: %s", err)
}
port := container.GetPort("5432/tcp")
// exponential backoff-retry, because the application in the container might not be ready to accept connections yet
pool.MaxWait = 120 * time.Second
if err := pool.Retry(func() error {
url := fmt.Sprintf("host=localhost port=%s user=test dbname=test password=test sslmode=disable", port)
db, err := sql.Open("pgx", url)
if err != nil {
return err
}
return db.Ping()
}); err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}
dbConfig := pgClient.Config{
Host: "localhost",
Port: port,
User: "test",
Pass: "test",
Name: "test",
SSLMode: "disable",
SSLCert: "",
SSLKey: "",
SSLRootCert: "",
}
if db, err = pgClient.Setup(dbConfig, *ipostgres.Migration()); err != nil {
log.Fatalf("Could not setup test DB connection: %s", err)
}
if db, err = pgClient.Connect(dbConfig); err != nil {
log.Fatalf("Could not setup test DB connection: %s", err)
}
database = postgres.NewDatabase(db, dbConfig, tracer)
code := m.Run()
// Defers will not be run when using os.Exit
db.Close()
if err := pool.Purge(container); err != nil {
log.Fatalf("Could not purge container: %s", err)
}
os.Exit(code)
}