Prepare backend to support notifications (#365)

Related to #245

Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
This commit is contained in:
Sergio C. Arteaga 2020-05-07 20:53:37 +02:00 committed by GitHub
parent 8e41191f9f
commit 6fddaf4139
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 2874 additions and 154 deletions

View File

@ -26,6 +26,8 @@ linters:
fast: false
linters-settings:
goconst:
min-occurrences: 5
golint:
min-confidence: 0

View File

@ -15,6 +15,7 @@ stringData:
user: {{ .Values.db.user }}
password: {{ .Values.db.password }}
server:
baseURL: {{ .Values.hub.server.baseURL }}
addr: 0.0.0.0:8000
metricsAddr: 0.0.0.0:8001
shutdownTimeout: 30s

View File

@ -38,6 +38,7 @@ hub:
cpu: 2
memory: 8000Mi
server:
baseURL: https://artifacthub.io
oauth:
github:
redirectURL: https://artifacthub.io/oauth/github/callback

View File

@ -39,6 +39,7 @@ hub:
cpu: 1
memory: 1000Mi
server:
baseURL: https://staging.artifacthub.io
oauth:
github:
redirectURL: https://staging.artifacthub.io/oauth/github/callback

View File

@ -32,6 +32,7 @@ hub:
cpu: 100m
memory: 500Mi
server:
baseURL: ""
basicAuth:
enabled: false
username: hub

View File

@ -12,6 +12,7 @@ import (
"github.com/artifacthub/hub/cmd/hub/handlers/org"
"github.com/artifacthub/hub/cmd/hub/handlers/pkg"
"github.com/artifacthub/hub/cmd/hub/handlers/static"
"github.com/artifacthub/hub/cmd/hub/handlers/subscription"
"github.com/artifacthub/hub/cmd/hub/handlers/user"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/img"
@ -29,6 +30,7 @@ type Services struct {
UserManager hub.UserManager
PackageManager hub.PackageManager
ChartRepositoryManager hub.ChartRepositoryManager
SubscriptionManager hub.SubscriptionManager
ImageStore img.Store
}
@ -51,6 +53,7 @@ type Handlers struct {
Users *user.Handlers
Packages *pkg.Handlers
ChartRepositories *chartrepo.Handlers
Subscriptions *subscription.Handlers
Static *static.Handlers
}
@ -65,6 +68,7 @@ func Setup(cfg *viper.Viper, svc *Services) *Handlers {
Organizations: org.NewHandlers(svc.OrganizationManager),
Users: user.NewHandlers(svc.UserManager, cfg),
Packages: pkg.NewHandlers(svc.PackageManager),
Subscriptions: subscription.NewHandlers(svc.SubscriptionManager),
ChartRepositories: chartrepo.NewHandlers(svc.ChartRepositoryManager),
Static: static.NewHandlers(cfg, svc.ImageStore),
}
@ -126,11 +130,18 @@ func (h *Handlers) setupRouter() {
r.With(h.Users.RequireLogin).Put("/", h.Packages.ToggleStar)
})
})
r.Route("/subscriptions", func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Get("/{packageID}", h.Subscriptions.GetByPackage)
r.Post("/", h.Subscriptions.Add)
r.Delete("/", h.Subscriptions.Delete)
})
r.Post("/users", h.Users.RegisterUser)
r.Route("/user", func(r chi.Router) {
r.Use(h.Users.RequireLogin)
r.Get("/", h.Users.GetProfile)
r.Get("/orgs", h.Organizations.GetByUser)
r.Get("/subscriptions", h.Subscriptions.GetByUser)
r.Put("/password", h.Users.UpdatePassword)
r.Put("/profile", h.Users.UpdateProfile)
r.Route("/chart-repositories", func(r chi.Router) {

View File

@ -29,7 +29,7 @@ func TestAdd(t *testing.T) {
t.Run("invalid organization provided", func(t *testing.T) {
testCases := []struct {
description string
repoJSON string
orgJSON string
omErr error
}{
{
@ -62,7 +62,7 @@ func TestAdd(t *testing.T) {
}
w := httptest.NewRecorder()
r, _ := http.NewRequest("POST", "/", strings.NewReader(tc.repoJSON))
r, _ := http.NewRequest("POST", "/", strings.NewReader(tc.orgJSON))
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.Add(w, r)
resp := w.Result()
@ -486,7 +486,7 @@ func TestUpdate(t *testing.T) {
t.Run("invalid organization provided", func(t *testing.T) {
testCases := []struct {
description string
repoJSON string
orgJSON string
omErr error
}{
{
@ -514,7 +514,7 @@ func TestUpdate(t *testing.T) {
}
w := httptest.NewRecorder()
r, _ := http.NewRequest("PUT", "/", strings.NewReader(tc.repoJSON))
r, _ := http.NewRequest("PUT", "/", strings.NewReader(tc.orgJSON))
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.Update(w, r)
resp := w.Result()

View File

@ -0,0 +1,97 @@
package subscription
import (
"encoding/json"
"errors"
"net/http"
"github.com/artifacthub/hub/cmd/hub/handlers/helpers"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/subscription"
"github.com/go-chi/chi"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// Handlers represents a group of http handlers in charge of handling
// subscriptions operations.
type Handlers struct {
subscriptionManager hub.SubscriptionManager
logger zerolog.Logger
}
// NewHandlers creates a new Handlers instance.
func NewHandlers(subscriptionManager hub.SubscriptionManager) *Handlers {
return &Handlers{
subscriptionManager: subscriptionManager,
logger: log.With().Str("handlers", "subscription").Logger(),
}
}
// Add is an http handler that adds the provided subscription to the database.
func (h *Handlers) Add(w http.ResponseWriter, r *http.Request) {
s := &hub.Subscription{}
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
h.logger.Error().Err(err).Str("method", "Add").Msg("invalid subscription")
http.Error(w, "subscription provided is not valid", http.StatusBadRequest)
return
}
if err := h.subscriptionManager.Add(r.Context(), s); err != nil {
h.logger.Error().Err(err).Str("method", "Add").Send()
if errors.Is(err, subscription.ErrInvalidInput) {
http.Error(w, err.Error(), http.StatusBadRequest)
} else {
http.Error(w, "", http.StatusInternalServerError)
}
return
}
}
// Delete is an http handler that removes the provided subscription from the
// database.
func (h *Handlers) Delete(w http.ResponseWriter, r *http.Request) {
s := &hub.Subscription{}
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
h.logger.Error().Err(err).Str("method", "Delete").Msg("invalid subscription")
http.Error(w, "subscription provided is not valid", http.StatusBadRequest)
return
}
if err := h.subscriptionManager.Delete(r.Context(), s); err != nil {
h.logger.Error().Err(err).Str("method", "Delete").Send()
if errors.Is(err, subscription.ErrInvalidInput) {
http.Error(w, err.Error(), http.StatusBadRequest)
} else {
http.Error(w, "", http.StatusInternalServerError)
}
return
}
}
// GetByPackage is an http handler that returns the subscriptions a user has
// for a given package.
func (h *Handlers) GetByPackage(w http.ResponseWriter, r *http.Request) {
packageID := chi.URLParam(r, "packageID")
dataJSON, err := h.subscriptionManager.GetByPackageJSON(r.Context(), packageID)
if err != nil {
h.logger.Error().Err(err).Str("method", "GetByPackage").Send()
if errors.Is(err, subscription.ErrInvalidInput) {
http.Error(w, err.Error(), http.StatusBadRequest)
} else {
http.Error(w, "", http.StatusInternalServerError)
}
return
}
helpers.RenderJSON(w, dataJSON, 0)
}
// GetByUser is an http handler that returns the subscriptions of the user
// doing the request.
func (h *Handlers) GetByUser(w http.ResponseWriter, r *http.Request) {
dataJSON, err := h.subscriptionManager.GetByUserJSON(r.Context())
if err != nil {
h.logger.Error().Err(err).Str("method", "GetByUser").Send()
http.Error(w, "", http.StatusInternalServerError)
return
}
helpers.RenderJSON(w, dataJSON, 0)
}

View File

@ -0,0 +1,304 @@
package subscription
import (
"context"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/artifacthub/hub/cmd/hub/handlers/helpers"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/subscription"
"github.com/artifacthub/hub/internal/tests"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestMain(m *testing.M) {
zerolog.SetGlobalLevel(zerolog.Disabled)
os.Exit(m.Run())
}
func TestAdd(t *testing.T) {
t.Run("invalid subscription provided", func(t *testing.T) {
testCases := []struct {
description string
subscriptionJSON string
smErr error
}{
{
"no subscription provided",
"",
nil,
},
{
"invalid json",
"-",
nil,
},
{
"invalid package id",
`{"package_id": "invalid"}`,
subscription.ErrInvalidInput,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.description, func(t *testing.T) {
hw := newHandlersWrapper()
if tc.smErr != nil {
hw.sm.On("Add", mock.Anything, mock.Anything).Return(tc.smErr)
}
w := httptest.NewRecorder()
r, _ := http.NewRequest("POST", "/", strings.NewReader(tc.subscriptionJSON))
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.Add(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
hw.sm.AssertExpectations(t)
})
}
})
t.Run("valid subscription provided", func(t *testing.T) {
subscriptionJSON := `
{
"package_id": "00000000-0000-0000-0000-000000000001",
"notification_kind": 0
}
`
testCases := []struct {
description string
err error
expectedStatusCode int
}{
{
"add subscription succeeded",
nil,
http.StatusOK,
},
{
"error adding subscription",
tests.ErrFakeDatabaseFailure,
http.StatusInternalServerError,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.description, func(t *testing.T) {
hw := newHandlersWrapper()
hw.sm.On("Add", mock.Anything, mock.Anything).Return(tc.err)
w := httptest.NewRecorder()
r, _ := http.NewRequest("POST", "/", strings.NewReader(subscriptionJSON))
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.Add(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, tc.expectedStatusCode, resp.StatusCode)
hw.sm.AssertExpectations(t)
})
}
})
}
func TestDelete(t *testing.T) {
t.Run("invalid subscription provided", func(t *testing.T) {
testCases := []struct {
description string
subscriptionJSON string
smErr error
}{
{
"no subscription provided",
"",
nil,
},
{
"invalid json",
"-",
nil,
},
{
"invalid package id",
`{"package_id": "invalid"}`,
subscription.ErrInvalidInput,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.description, func(t *testing.T) {
hw := newHandlersWrapper()
if tc.smErr != nil {
hw.sm.On("Delete", mock.Anything, mock.Anything).Return(tc.smErr)
}
w := httptest.NewRecorder()
r, _ := http.NewRequest("DELETE", "/", strings.NewReader(tc.subscriptionJSON))
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.Delete(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
hw.sm.AssertExpectations(t)
})
}
})
t.Run("valid subscription provided", func(t *testing.T) {
subscriptionJSON := `
{
"package_id": "00000000-0000-0000-0000-000000000001",
"notification_kind": 0
}
`
testCases := []struct {
description string
err error
expectedStatusCode int
}{
{
"delete subscription succeeded",
nil,
http.StatusOK,
},
{
"error deleting subscription",
tests.ErrFakeDatabaseFailure,
http.StatusInternalServerError,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.description, func(t *testing.T) {
hw := newHandlersWrapper()
hw.sm.On("Delete", mock.Anything, mock.Anything).Return(tc.err)
w := httptest.NewRecorder()
r, _ := http.NewRequest("DELETE", "/", strings.NewReader(subscriptionJSON))
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.Delete(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, tc.expectedStatusCode, resp.StatusCode)
hw.sm.AssertExpectations(t)
})
}
})
}
func TestGetByPackage(t *testing.T) {
t.Run("error getting package subscriptions", func(t *testing.T) {
testCases := []struct {
smErr error
expectedStatusCode int
}{
{
subscription.ErrInvalidInput,
http.StatusBadRequest,
},
{
tests.ErrFakeDatabaseFailure,
http.StatusInternalServerError,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.smErr.Error(), func(t *testing.T) {
hw := newHandlersWrapper()
hw.sm.On("GetByPackageJSON", mock.Anything, mock.Anything).Return(nil, tc.smErr)
w := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", nil)
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.GetByPackage(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, tc.expectedStatusCode, resp.StatusCode)
hw.sm.AssertExpectations(t)
})
}
})
t.Run("get package subscriptions succeeded", func(t *testing.T) {
hw := newHandlersWrapper()
hw.sm.On("GetByPackageJSON", mock.Anything, mock.Anything).Return([]byte("dataJSON"), nil)
w := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", nil)
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.GetByPackage(w, r)
resp := w.Result()
defer resp.Body.Close()
h := resp.Header
data, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/json", h.Get("Content-Type"))
assert.Equal(t, helpers.BuildCacheControlHeader(0), h.Get("Cache-Control"))
assert.Equal(t, []byte("dataJSON"), data)
hw.sm.AssertExpectations(t)
})
}
func TestGetByUser(t *testing.T) {
t.Run("error getting user subscriptions", func(t *testing.T) {
hw := newHandlersWrapper()
hw.sm.On("GetByUserJSON", mock.Anything).Return(nil, tests.ErrFakeDatabaseFailure)
w := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", nil)
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.GetByUser(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
hw.sm.AssertExpectations(t)
})
t.Run("get user subscriptions succeeded", func(t *testing.T) {
hw := newHandlersWrapper()
hw.sm.On("GetByUserJSON", mock.Anything).Return([]byte("dataJSON"), nil)
w := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", nil)
r = r.WithContext(context.WithValue(r.Context(), hub.UserIDKey, "userID"))
hw.h.GetByUser(w, r)
resp := w.Result()
defer resp.Body.Close()
h := resp.Header
data, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/json", h.Get("Content-Type"))
assert.Equal(t, helpers.BuildCacheControlHeader(0), h.Get("Cache-Control"))
assert.Equal(t, []byte("dataJSON"), data)
hw.sm.AssertExpectations(t)
})
}
type handlersWrapper struct {
sm *subscription.ManagerMock
h *Handlers
}
func newHandlersWrapper() *handlersWrapper {
sm := &subscription.ManagerMock{}
return &handlersWrapper{
sm: sm,
h: NewHandlers(sm),
}
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
@ -13,8 +14,10 @@ import (
"github.com/artifacthub/hub/internal/email"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/img/pg"
"github.com/artifacthub/hub/internal/notification"
"github.com/artifacthub/hub/internal/org"
"github.com/artifacthub/hub/internal/pkg"
"github.com/artifacthub/hub/internal/subscription"
"github.com/artifacthub/hub/internal/user"
"github.com/artifacthub/hub/internal/util"
"github.com/prometheus/client_golang/prometheus/promhttp"
@ -32,7 +35,7 @@ func main() {
log.Fatal().Err(err).Msg("Logger setup failed")
}
// Setup services required by the handlers to operate
// Setup database and email services
db, err := util.SetupDB(cfg)
if err != nil {
log.Fatal().Err(err).Msg("Database setup failed")
@ -41,22 +44,23 @@ func main() {
if s := email.NewSender(cfg); s != nil {
es = s
}
svc := &handlers.Services{
// Setup and launch server
hSvc := &handlers.Services{
OrganizationManager: org.NewManager(db, es),
UserManager: user.NewManager(db, es),
PackageManager: pkg.NewManager(db),
SubscriptionManager: subscription.NewManager(db),
ChartRepositoryManager: chartrepo.NewManager(db),
ImageStore: pg.NewImageStore(db),
}
// Setup and launch server
addr := cfg.GetString("server.addr")
srv := &http.Server{
Addr: addr,
ReadTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 1 * time.Minute,
Handler: handlers.Setup(cfg, svc).Router,
Handler: handlers.Setup(cfg, hSvc).Router,
}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
@ -74,11 +78,27 @@ func main() {
}
}()
// Setup and launch notifications dispatcher
nSvc := &notification.Services{
DB: db,
ES: es,
NotificationManager: notification.NewManager(),
SubscriptionManager: subscription.NewManager(db),
PackageManager: pkg.NewManager(db),
}
notificationsDispatcher := notification.NewDispatcher(cfg, nSvc)
ctx, stopNotificationsDispatcher := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go notificationsDispatcher.Run(ctx, &wg)
// Shutdown server gracefully when SIGINT or SIGTERM signal is received
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
<-shutdown
log.Info().Msg("Hub server shutting down..")
stopNotificationsDispatcher()
wg.Wait()
ctx, cancel := context.WithTimeout(context.Background(), cfg.GetDuration("server.shutdownTimeout"))
defer cancel()
if err := srv.Shutdown(ctx); err != nil {

View File

@ -39,6 +39,14 @@
{{ template "images/get_image.sql" }}
{{ template "images/register_image.sql" }}
{{ template "subscriptions/add_subscription.sql" }}
{{ template "subscriptions/delete_subscription.sql" }}
{{ template "subscriptions/get_package_subscriptions.sql" }}
{{ template "subscriptions/get_subscriptors.sql" }}
{{ template "subscriptions/get_user_subscriptions.sql" }}
{{ template "notifications/get_pending_notification.sql" }}
---- create above / drop below ----
-- Nothing to do

View File

@ -0,0 +1,35 @@
-- get_pending_notification returns a pending notification if available,
-- updating its processed state if the notification is delivered successfully.
-- This function should be called from a transaction that should be rolled back
-- if the notification is not delivered successfully.
create or replace function get_pending_notification()
returns setof json as $$
declare
v_notification_id uuid;
v_notification json;
begin
-- Get pending notification if available
select notification_id, json_build_object(
'notification_id', n.notification_id,
'package_version', n.package_version,
'package_id', n.package_id,
'notification_kind', n.notification_kind_id
) into v_notification_id, v_notification
from notification n
where n.processed = false
for update of n skip locked
limit 1;
if not found then
return;
end if;
-- Update notification processed state
-- (this will be committed once the notification is delivered successfully)
update notification set
processed = true,
processed_at = current_timestamp
where notification_id = v_notification_id;
return query select v_notification;
end
$$ language plpgsql;

View File

@ -7,7 +7,9 @@ declare
v_package_name text := p_input->>'package_name';
v_chart_repository_name text := p_input->>'chart_repository_name';
begin
if v_chart_repository_name <> '' then
if p_input->>'package_id' <> '' then
v_package_id = p_input->>'package_id';
elsif v_chart_repository_name <> '' then
select p.package_id into v_package_id
from package p
join chart_repository r using (chart_repository_id)

View File

@ -102,7 +102,7 @@ returns setof json as $$
on p.organization_id = o.organization_id or r.organization_id = o.organization_id
where s.version = p.latest_version
and (s.deprecated is null or s.deprecated = false)
order by updated_at desc limit 5
order by p.updated_at desc limit 5
) as pru
)
);

View File

@ -6,6 +6,7 @@
create or replace function register_package(p_pkg jsonb)
returns void as $$
declare
v_previous_latest_version text;
v_package_id uuid;
v_name text := p_pkg->>'name';
v_display_name text := nullif(p_pkg->>'display_name', '');
@ -14,10 +15,16 @@ declare
select (array(select jsonb_array_elements_text(nullif(p_pkg->'keywords', 'null'::jsonb))))::text[]
);
v_chart_repository_id text := (p_pkg->'chart_repository')->>'chart_repository_id';
v_package_latest_version_needs_update boolean := false;
v_maintainer jsonb;
v_maintainer_id uuid;
begin
-- Get package's latest version before registration, if available
select latest_version into v_previous_latest_version
from package
where package_kind_id = (p_pkg->>'kind')::int
and chart_repository_id = nullif(v_chart_repository_id, '')::uuid
and name = v_name;
-- Package
insert into package (
name,
@ -131,6 +138,19 @@ begin
digest = excluded.digest,
readme = excluded.readme,
links = excluded.links,
deprecated = excluded.deprecated;
deprecated = excluded.deprecated,
updated_at = current_timestamp;
-- Register new release notification if package's latest version has been
-- updated and there are subscriptors for this package and notification kind
if semver_gte(p_pkg->>'version', v_previous_latest_version) then
perform * from subscription
where notification_kind_id = 0 -- New package release
and package_id = v_package_id;
if found then
insert into notification (package_id, package_version, notification_kind_id)
values (v_package_id, p_pkg->>'version', 0);
end if;
end if;
end
$$ language plpgsql;

View File

@ -0,0 +1,13 @@
-- add_subscription adds the provided subscription to the database.
create or replace function add_subscription(p_subscription jsonb)
returns void as $$
insert into subscription (
user_id,
package_id,
notification_kind_id
) values (
(p_subscription->>'user_id')::uuid,
(p_subscription->>'package_id')::uuid,
(p_subscription->>'notification_kind')::int
);
$$ language sql;

View File

@ -0,0 +1,8 @@
-- delete_subscription deletes the provided subscription from the database.
create or replace function delete_subscription(p_subscription jsonb)
returns void as $$
delete from subscription
where user_id = (p_subscription->>'user_id')::uuid
and package_id = (p_subscription->>'package_id')::uuid
and notification_kind_id = (p_subscription->>'notification_kind')::int;
$$ language sql;

View File

@ -0,0 +1,15 @@
-- get_package_subscriptions returns the subscriptions the provided user has
-- for a given package as a json array.
create or replace function get_package_subscriptions(p_user_id uuid, p_package_id uuid)
returns setof json as $$
select coalesce(json_agg(json_build_object(
'notification_kind', notification_kind_id
)), '[]')
from (
select *
from subscription
where user_id = p_user_id
and package_id = p_package_id
order by notification_kind_id asc
) s;
$$ language sql;

View File

@ -0,0 +1,12 @@
-- get_subscriptors returns the users subscribed to the package provided for
-- the given notification kind.
create or replace function get_subscriptors(p_package_id uuid, p_notification_kind int)
returns setof json as $$
select coalesce(json_agg(json_build_object(
'email', u.email
)), '[]')
from subscription s
join "user" u using (user_id)
where s.package_id = p_package_id
and s.notification_kind_id = p_notification_kind;
$$ language sql;

View File

@ -0,0 +1,50 @@
-- get_user_subscriptions returns all the subscriptions for the provided user
-- as a json array.
create or replace function get_user_subscriptions(p_user_id uuid)
returns setof json as $$
select coalesce(json_agg(json_build_object(
'package_id', package_id,
'kind', package_kind_id,
'name', name,
'normalized_name', normalized_name,
'logo_image_id', logo_image_id,
'user_alias', user_alias,
'organization_name', organization_name,
'organization_display_name', organization_display_name,
'chart_repository', (select nullif(
jsonb_build_object(
'name', chart_repository_name,
'display_name', chart_repository_display_name
),
'{"name": null, "display_name": null}'::jsonb
)),
'notification_kinds', (
select json_agg(distinct(notification_kind_id))
from subscription
where package_id = sp.package_id
and user_id = p_user_id
)
)), '[]')
from (
select
p.package_id,
p.package_kind_id,
p.name,
p.normalized_name,
p.logo_image_id,
u.alias as user_alias,
o.name as organization_name,
o.display_name as organization_display_name,
r.name as chart_repository_name,
r.display_name as chart_repository_display_name
from package p
left join chart_repository r using (chart_repository_id)
left join "user" u on p.user_id = u.user_id or r.user_id = u.user_id
left join organization o
on p.organization_id = o.organization_id or r.organization_id = o.organization_id
where p.package_id in (
select distinct(package_id) from subscription where user_id = p_user_id
)
order by p.normalized_name asc
) sp;
$$ language sql;

View File

@ -111,6 +111,8 @@ create table if not exists snapshot (
links jsonb,
data jsonb,
deprecated boolean,
created_at timestamptz default current_timestamp not null,
updated_at timestamptz default current_timestamp not null,
primary key (package_id, version)
);
@ -144,6 +146,34 @@ create table if not exists user_starred_package (
primary key (user_id, package_id)
);
create table if not exists notification_kind (
notification_kind_id integer primary key,
name text not null check (name <> '')
);
insert into notification_kind values (0, 'New package release');
insert into notification_kind values (1, 'Security alert');
create table notification (
notification_id uuid primary key default gen_random_uuid(),
created_at timestamptz default current_timestamp not null,
processed boolean not null default false,
processed_at timestamptz,
package_version text not null check (package_version <> ''),
package_id uuid not null references package on delete cascade,
notification_kind_id integer not null references notification_kind on delete restrict,
unique (package_id, package_version)
);
create index notification_not_processed_idx on notification (notification_id) where processed = 'false';
create table if not exists subscription (
user_id uuid not null references "user" on delete cascade,
package_id uuid not null references package on delete cascade,
notification_kind_id integer not null references notification_kind on delete restrict,
primary key (user_id, package_id, notification_kind_id)
);
{{ if eq .loadSampleData "true" }}
{{ template "data/sample.sql" }}
{{ end }}

View File

@ -0,0 +1,66 @@
-- Start transaction and plan tests
begin;
select plan(4);
-- Declare some variables
\set notification1ID '00000000-0000-0000-0000-000000000001'
\set package1ID '00000000-0000-0000-0000-000000000001'
-- No pending notifications available yet
select is_empty(
$$ select get_pending_notification()::jsonb $$,
'Should not return a notification'
);
-- Seed some data
insert into package (
package_id,
name,
latest_version,
package_kind_id
) values (
:'package1ID',
'Package 1',
'1.0.0',
1
);
insert into notification (notification_id, package_version, package_id, notification_kind_id)
values (:'notification1ID', '1.0.0', :'package1ID', 0);
savepoint before_getting_notification;
-- Run some tests
select is(
get_pending_notification()::jsonb,
'{
"notification_id": "00000000-0000-0000-0000-000000000001",
"package_version": "1.0.0",
"package_id": "00000000-0000-0000-0000-000000000001",
"notification_kind": 0
}'::jsonb,
'Notification should be returned'
);
select results_eq(
$$
select processed from notification
where notification_id = '00000000-0000-0000-0000-000000000001'
$$,
$$
values (true)
$$,
'Notification should be marked as processed'
);
rollback to before_getting_notification;
select results_eq(
$$
select processed from notification
where notification_id = '00000000-0000-0000-0000-000000000001'
$$,
$$
values (false)
$$,
'Notification should not be marked as processed as transaction was rolled back'
);
-- Finish tests and rollback transaction
select * from finish();
rollback;

View File

@ -1,6 +1,6 @@
-- Start transaction and plan tests
begin;
select plan(4);
select plan(5);
-- Declare some variables
\set org1ID '00000000-0000-0000-0000-000000000001'
@ -76,7 +76,7 @@ insert into snapshot (
'12.1.0',
'digest-package1-1.0.0',
'readme-version-1.0.0',
'{"link1": "https://link1", "link2": "https://link2"}',
'[{"name": "link1", "url": "https://link1"}, {"name": "link2", "url": "https://link2"}]',
'{"key": "value"}',
true
);
@ -102,7 +102,7 @@ insert into snapshot (
'12.0.0',
'digest-package1-0.0.9',
'readme-version-0.0.9',
'{"link1": "https://link1", "link2": "https://link2"}',
'[{"name": "link1", "url": "https://link1"}, {"name": "link2", "url": "https://link2"}]',
'{"key": "value"}'
);
insert into package (
@ -139,6 +139,61 @@ insert into snapshot (
);
-- Run some tests
select is(
get_package('{
"package_id": "00000000-0000-0000-0000-000000000001"
}')::jsonb,
'{
"package_id": "00000000-0000-0000-0000-000000000001",
"kind": 0,
"name": "Package 1",
"normalized_name": "package-1",
"logo_image_id": "00000000-0000-0000-0000-000000000001",
"display_name": "Package 1",
"description": "description",
"keywords": ["kw1", "kw2"],
"home_url": "home_url",
"readme": "readme-version-1.0.0",
"links": [
{
"name": "link1",
"url": "https://link1"
},
{
"name": "link2",
"url": "https://link2"
}
],
"data": {
"key": "value"
},
"version": "1.0.0",
"available_versions": ["0.0.9", "1.0.0"],
"app_version": "12.1.0",
"digest": "digest-package1-1.0.0",
"deprecated": true,
"maintainers": [
{
"name": "name1",
"email": "email1"
},
{
"name": "name2",
"email": "email2"
}
],
"user_alias": "user1",
"organization_name": null,
"organization_display_name": null,
"chart_repository": {
"chart_repository_id": "00000000-0000-0000-0000-000000000001",
"name": "repo1",
"display_name": "Repo 1",
"url": "https://repo1.com"
}
}'::jsonb,
'Last package1 version is returned as a json object'
);
select is(
get_package('{
"package_name": "package-1",
@ -155,10 +210,16 @@ select is(
"keywords": ["kw1", "kw2"],
"home_url": "home_url",
"readme": "readme-version-1.0.0",
"links": {
"link1": "https://link1",
"link2": "https://link2"
},
"links": [
{
"name": "link1",
"url": "https://link1"
},
{
"name": "link2",
"url": "https://link2"
}
],
"data": {
"key": "value"
},
@ -206,10 +267,16 @@ select is(
"keywords": ["kw1", "kw2", "older"],
"home_url": "home_url (older)",
"readme": "readme-version-0.0.9",
"links": {
"link1": "https://link1",
"link2": "https://link2"
},
"links": [
{
"name": "link1",
"url": "https://link1"
},
{
"name": "link2",
"url": "https://link2"
}
],
"data": {
"key": "value"
},

View File

@ -40,8 +40,7 @@ insert into snapshot (
description,
app_version,
digest,
readme,
links
readme
) values (
:'package1ID',
'1.0.0',
@ -49,8 +48,7 @@ insert into snapshot (
'description',
'12.1.0',
'digest-package1-1.0.0',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into snapshot (
package_id,
@ -59,8 +57,7 @@ insert into snapshot (
description,
app_version,
digest,
readme,
links
readme
) values (
:'package1ID',
'0.0.9',
@ -68,8 +65,7 @@ insert into snapshot (
'description',
'12.0.0',
'digest-package1-0.0.9',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into package (
package_id,
@ -96,7 +92,6 @@ insert into snapshot (
app_version,
digest,
readme,
links,
deprecated
) values (
:'package2ID',
@ -106,7 +101,6 @@ insert into snapshot (
'12.1.0',
'digest-package2-1.0.0',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}',
true
);
insert into user_starred_package (user_id, package_id) values (:'user1ID', :'package1ID');

View File

@ -45,8 +45,7 @@ insert into snapshot (
home_url,
app_version,
digest,
readme,
links
readme
) values (
:'package1ID',
'1.0.0',
@ -55,8 +54,7 @@ insert into snapshot (
'home_url',
'12.1.0',
'digest-package1-1.0.0',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into snapshot (
package_id,
@ -66,8 +64,7 @@ insert into snapshot (
home_url,
app_version,
digest,
readme,
links
readme
) values (
:'package1ID',
'0.0.9',
@ -76,8 +73,7 @@ insert into snapshot (
'home_url',
'12.0.0',
'digest-package1-0.0.9',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into package (
package_id,
@ -102,8 +98,7 @@ insert into snapshot (
home_url,
app_version,
digest,
readme,
links
readme
) values (
:'package2ID',
'1.0.0',
@ -112,8 +107,7 @@ insert into snapshot (
'home_url',
'12.1.0',
'digest-package2-1.0.0',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into snapshot (
package_id,
@ -123,8 +117,7 @@ insert into snapshot (
home_url,
app_version,
digest,
readme,
links
readme
) values (
:'package2ID',
'0.0.9',
@ -133,8 +126,7 @@ insert into snapshot (
'home_url',
'12.0.0',
'digest-package2-0.0.9',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
-- Some packages have just been seeded

View File

@ -59,7 +59,6 @@ insert into snapshot (
keywords,
home_url,
readme,
links,
deprecated
) values (
:'package1ID',
@ -69,7 +68,6 @@ insert into snapshot (
'{"kw1", "kw2"}',
'home_url',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}',
false
);
insert into package (
@ -102,8 +100,7 @@ insert into snapshot (
home_url,
app_version,
digest,
readme,
links
readme
) values (
:'package2ID',
'1.0.0',
@ -113,8 +110,7 @@ insert into snapshot (
'home_url',
'12.1.0',
'digest-package2-1.0.0',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into package (
package_id,
@ -146,7 +142,6 @@ insert into snapshot (
app_version,
digest,
readme,
links,
deprecated
) values (
:'package3ID',
@ -158,7 +153,6 @@ insert into snapshot (
'12.1.0',
'digest-package3-1.0.0',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}',
true
);

View File

@ -1,16 +1,18 @@
-- Start transaction and plan tests
begin;
select plan(11);
select plan(16);
-- Declare some variables
\set org1ID '00000000-0000-0000-0000-000000000001'
\set repo1ID '00000000-0000-0000-0000-000000000001'
\set user1ID '00000000-0000-0000-0000-000000000001'
-- Seed some data
insert into organization (organization_id, name, display_name, description, home_url)
values (:'org1ID', 'org1', 'Organization 1', 'Description 1', 'https://org1.com');
insert into chart_repository (chart_repository_id, name, display_name, url)
values (:'repo1ID', 'repo1', 'Repo 1', 'https://repo1.com');
insert into "user" (user_id, alias, email) values (:'user1ID', 'user1', 'user1@email.com');
-- Register package
select register_package('
@ -24,10 +26,16 @@ select register_package('
"keywords": ["kw1", "kw2"],
"home_url": "home_url",
"readme": "readme-version-1.0.0",
"links": {
"link1": "https://link1",
"link2": "https://link2"
},
"links": [
{
"name": "link1",
"url": "https://link1"
},
{
"name": "link2",
"url": "https://link2"
}
],
"data": {
"key": "value"
},
@ -105,7 +113,7 @@ select results_eq(
'12.1.0',
'digest-package1-1.0.0',
'readme-version-1.0.0',
'{"link1": "https://link1", "link2": "https://link2"}'::jsonb,
'[{"name": "link1", "url": "https://link1"}, {"name": "link2", "url": "https://link2"}]'::jsonb,
'{"key": "value"}'::jsonb,
false
)
@ -130,6 +138,20 @@ select results_eq(
$$,
'Maintainers should exist'
);
select is_empty(
$$
select *
from notification n
join package p using (package_id)
where p.name = 'package1'
$$,
'No new release notifications should exist for first version of package1'
);
-- Subscribe user1 to package1 new releases notifications
insert into subscription (user_id, package_id, notification_kind_id)
select :'user1ID', package_id, 0
from package where name = 'package1';
-- Register a new version of the package previously registered
select register_package('
@ -227,6 +249,16 @@ select is_empty(
$$,
'Orphan maintainers were deleted'
);
select isnt_empty(
$$
select *
from notification n
join package p using (package_id)
where p.name = 'package1'
and n.package_version = '2.0.0'
$$,
'New release notification should exist for package1 version 2.0.0'
);
-- Register an old version of the package previously registered
select register_package('
@ -318,6 +350,16 @@ select results_eq(
$$ values ('name1', 'email1') $$,
'Package maintainers should not have been updated'
);
select is_empty(
$$
select *
from notification n
join package p using (package_id)
where p.name = 'package1'
and n.package_version = '0.0.9'
$$,
'No new release notifications should exist for package1 version 0.0.9'
);
-- Register package that belongs to an organization and check it succeeded
select register_package('
@ -352,6 +394,38 @@ select results_eq(
$$,
'Package that belongs to organization should exist'
);
select is_empty(
$$
select *
from notification n
join package p using (package_id)
where p.name = 'package3'
and n.package_version = '1.0.0'
$$,
'No new release notifications should exist for first version of package3'
);
-- Register a new version of the package previously registered
select register_package('
{
"kind": 1,
"name": "package3",
"display_name": "Package 3",
"description": "description",
"version": "2.0.0",
"organization_id": "00000000-0000-0000-0000-000000000001"
}
');
select is_empty(
$$
select *
from notification n
join package p using (package_id)
where p.name = 'package3'
and n.package_version = '2.0.0'
$$,
'No new release notifications should exist for new version of package3 (no subscriptors)'
);
-- Finish tests and rollback transaction
select * from finish();

View File

@ -69,8 +69,7 @@ insert into snapshot (
home_url,
app_version,
digest,
readme,
links
readme
) values (
:'package1ID',
'1.0.0',
@ -80,8 +79,7 @@ insert into snapshot (
'home_url',
'12.1.0',
'digest-package1-1.0.0',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into snapshot (
package_id,
@ -92,8 +90,7 @@ insert into snapshot (
home_url,
app_version,
digest,
readme,
links
readme
) values (
:'package1ID',
'0.0.9',
@ -103,8 +100,7 @@ insert into snapshot (
'home_url',
'12.0.0',
'digest-package1-0.0.9',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into package (
package_id,
@ -135,7 +131,6 @@ insert into snapshot (
app_version,
digest,
readme,
links,
deprecated
) values (
:'package2ID',
@ -147,7 +142,6 @@ insert into snapshot (
'12.1.0',
'digest-package2-1.0.0',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}',
true
);
insert into snapshot (
@ -159,8 +153,7 @@ insert into snapshot (
home_url,
app_version,
digest,
readme,
links
readme
) values (
:'package2ID',
'0.0.9',
@ -170,8 +163,7 @@ insert into snapshot (
'home_url',
'12.0.0',
'digest-package2-0.0.9',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
insert into package (
package_id,
@ -196,16 +188,14 @@ insert into snapshot (
display_name,
description,
keywords,
readme,
links
readme
) values (
:'package3ID',
'1.0.0',
'Package 3',
'description',
'{"kw3"}',
'readme',
'{"link1": "https://link1", "link2": "https://link2"}'
'readme'
);
-- Some packages have just been seeded

View File

@ -0,0 +1,54 @@
-- Start transaction and plan tests
begin;
select plan(1);
-- Declare some variables
\set user1ID '00000000-0000-0000-0000-000000000001'
\set package1ID '00000000-0000-0000-0000-000000000001'
-- Seed some data
insert into "user" (user_id, alias, email)
values (:'user1ID', 'user1', 'user1@email.com');
insert into package (
package_id,
name,
latest_version,
package_kind_id
) values (
:'package1ID',
'Package 1',
'1.0.0',
1
);
-- Add subscription
select add_subscription('
{
"user_id": "00000000-0000-0000-0000-000000000001",
"package_id": "00000000-0000-0000-0000-000000000001",
"notification_kind": 0
}
'::jsonb);
-- Check if subscription was added successfully
select results_eq(
$$
select
user_id,
package_id,
notification_kind_id
from subscription
$$,
$$
values (
'00000000-0000-0000-0000-000000000001'::uuid,
'00000000-0000-0000-0000-000000000001'::uuid,
0
)
$$,
'Subscription should exist'
);
-- Finish tests and rollback transaction
select * from finish();
rollback;

View File

@ -0,0 +1,49 @@
-- Start transaction and plan tests
begin;
select plan(1);
-- Declare some variables
\set user1ID '00000000-0000-0000-0000-000000000001'
\set package1ID '00000000-0000-0000-0000-000000000001'
-- Seed some data
insert into "user" (user_id, alias, email)
values (:'user1ID', 'user1', 'user1@email.com');
insert into package (
package_id,
name,
latest_version,
package_kind_id
) values (
:'package1ID',
'Package 1',
'1.0.0',
1
);
insert into subscription (user_id, package_id, notification_kind_id)
values (:'user1ID', :'package1ID', 0);
-- Delete subscription
select delete_subscription('
{
"user_id": "00000000-0000-0000-0000-000000000001",
"package_id": "00000000-0000-0000-0000-000000000001",
"notification_kind": 0
}
'::jsonb);
-- Check if subscription was deleted successfully
select is_empty(
$$
select *
from subscription
where user_id = '00000000-0000-0000-0000-000000000001'
and package_id = '00000000-0000-0000-0000-000000000001'
and notification_kind_id = 0
$$,
'Subscription should not exist'
);
-- Finish tests and rollback transaction
select * from finish();
rollback;

View File

@ -0,0 +1,52 @@
-- Start transaction and plan tests
begin;
select plan(3);
-- Declare some variables
\set user1ID '00000000-0000-0000-0000-000000000001'
\set user2ID '00000000-0000-0000-0000-000000000002'
\set package1ID '00000000-0000-0000-0000-000000000001'
\set package2ID '00000000-0000-0000-0000-000000000002'
-- Seed some data
insert into "user" (user_id, alias, email)
values (:'user1ID', 'user1', 'user1@email.com');
insert into "user" (user_id, alias, email)
values (:'user2ID', 'user2', 'user2@email.com');
insert into package (
package_id,
name,
latest_version,
package_kind_id
) values (
:'package1ID',
'Package 1',
'1.0.0',
1
);
insert into subscription (user_id, package_id, notification_kind_id)
values (:'user1ID', :'package1ID', 0);
-- Run some tests
select is(
get_package_subscriptions(:'user1ID', :'package1ID')::jsonb,
'[{
"notification_kind": 0
}]'::jsonb,
'A subscription with notification kind 0 should be returned'
);
select is(
get_package_subscriptions(:'user2ID', :'package1ID')::jsonb,
'[]'::jsonb,
'No subscriptions should be returned for user2 and package1'
);
select is(
get_package_subscriptions(:'user1ID', :'package2ID')::jsonb,
'[]'::jsonb,
'No subscriptions should be returned for user1 and package2'
);
-- Finish tests and rollback transaction
select * from finish();
rollback;

View File

@ -0,0 +1,58 @@
-- Start transaction and plan tests
begin;
select plan(2);
-- Declare some variables
\set user1ID '00000000-0000-0000-0000-000000000001'
\set user2ID '00000000-0000-0000-0000-000000000002'
\set user3ID '00000000-0000-0000-0000-000000000003'
\set package1ID '00000000-0000-0000-0000-000000000001'
\set package2ID '00000000-0000-0000-0000-000000000002'
-- Seed some data
insert into "user" (user_id, alias, email)
values (:'user1ID', 'user1', 'user1@email.com');
insert into "user" (user_id, alias, email)
values (:'user2ID', 'user2', 'user2@email.com');
insert into "user" (user_id, alias, email)
values (:'user3ID', 'user3', 'user3@email.com');
insert into package (
package_id,
name,
latest_version,
package_kind_id
) values (
:'package1ID',
'Package 1',
'1.0.0',
1
);
insert into subscription (user_id, package_id, notification_kind_id)
values (:'user1ID', :'package1ID', 0);
insert into subscription (user_id, package_id, notification_kind_id)
values (:'user2ID', :'package1ID', 0);
insert into subscription (user_id, package_id, notification_kind_id)
values (:'user3ID', :'package1ID', 1);
-- Run some tests
select is(
get_subscriptors(:'package1ID', 0)::jsonb,
'[
{
"email": "user1@email.com"
},
{
"email": "user2@email.com"
}
]'::jsonb,
'Two subscriptors expected for package1 and kind new releases'
);
select is(
get_subscriptors(:'package2ID', 0)::jsonb,
'[]'::jsonb,
'No subscriptors expected for package2 and kind new releases'
);
-- Finish tests and rollback transaction
select * from finish();
rollback;

View File

@ -0,0 +1,100 @@
-- Start transaction and plan tests
begin;
select plan(2);
-- Declare some variables
\set org1ID '00000000-0000-0000-0000-000000000001'
\set user1ID '00000000-0000-0000-0000-000000000001'
\set user2ID '00000000-0000-0000-0000-000000000002'
\set repo1ID '00000000-0000-0000-0000-000000000001'
\set package1ID '00000000-0000-0000-0000-000000000001'
\set package2ID '00000000-0000-0000-0000-000000000002'
\set image1ID '00000000-0000-0000-0000-000000000001'
\set image2ID '00000000-0000-0000-0000-000000000002'
-- Seed some data
insert into organization (organization_id, name, display_name, description, home_url)
values (:'org1ID', 'org1', 'Organization 1', 'Description 1', 'https://org1.com');
insert into "user" (user_id, alias, email)
values (:'user1ID', 'user1', 'user1@email.com');
insert into "user" (user_id, alias, email)
values (:'user2ID', 'user2', 'user2@email.com');
insert into chart_repository (chart_repository_id, name, display_name, url, user_id)
values (:'repo1ID', 'repo1', 'Repo 1', 'https://repo1.com', :'user1ID');
insert into package (
package_id,
name,
latest_version,
logo_image_id,
package_kind_id,
chart_repository_id
) values (
:'package1ID',
'Package 1',
'1.0.0',
:'image1ID',
0,
:'repo1ID'
);
insert into package (
package_id,
name,
latest_version,
logo_image_id,
package_kind_id,
organization_id
) values (
:'package2ID',
'Package 2',
'1.0.0',
:'image2ID',
1,
:'org1ID'
);
insert into subscription (user_id, package_id, notification_kind_id)
values (:'user1ID', :'package1ID', 0);
insert into subscription (user_id, package_id, notification_kind_id)
values (:'user1ID', :'package1ID', 1);
insert into subscription (user_id, package_id, notification_kind_id)
values (:'user1ID', :'package2ID', 0);
-- Run some tests
select is(
get_user_subscriptions(:'user1ID')::jsonb,
'[{
"package_id": "00000000-0000-0000-0000-000000000001",
"kind": 0,
"name": "Package 1",
"normalized_name": "package-1",
"logo_image_id": "00000000-0000-0000-0000-000000000001",
"user_alias": "user1",
"organization_name": null,
"organization_display_name": null,
"chart_repository": {
"name": "repo1",
"display_name": "Repo 1"
},
"notification_kinds": [0, 1]
}, {
"package_id": "00000000-0000-0000-0000-000000000002",
"kind": 1,
"name": "Package 2",
"normalized_name": "package-2",
"logo_image_id": "00000000-0000-0000-0000-000000000002",
"user_alias": null,
"organization_name": "org1",
"organization_display_name": "Organization 1",
"chart_repository": null,
"notification_kinds": [0]
}]'::jsonb,
'Two subscriptions should be returned'
);
select is(
get_user_subscriptions(:'user2ID')::jsonb,
'[]',
'No subscriptions expected for user2'
);
-- Finish tests and rollback transaction
select * from finish();
rollback;

View File

@ -1,6 +1,6 @@
-- Start transaction and plan tests
begin;
select plan(62);
select plan(82);
-- Check default_text_search_config is correct
select results_eq(
@ -19,12 +19,15 @@ select tables_are(array[
'image',
'image_version',
'maintainer',
'notification',
'notification_kind',
'organization',
'package',
'package__maintainer',
'package_kind',
'session',
'snapshot',
'subscription',
'user',
'user_starred_package',
'user__organization',
@ -62,6 +65,19 @@ select columns_are('maintainer', array[
'name',
'email'
]);
select columns_are('notification', array[
'notification_id',
'created_at',
'processed',
'processed_at',
'package_version',
'package_id',
'notification_kind_id'
]);
select columns_are('notification_kind', array[
'notification_kind_id',
'name'
]);
select columns_are('organization', array[
'organization_id',
'name',
@ -114,7 +130,14 @@ select columns_are('snapshot', array[
'readme',
'links',
'data',
'deprecated'
'deprecated',
'created_at',
'updated_at'
]);
select columns_are('subscription', array[
'user_id',
'package_id',
'notification_kind_id'
]);
select columns_are('user', array[
'user_id',
@ -148,10 +171,30 @@ select indexes_are('chart_repository', array[
'chart_repository_name_key',
'chart_repository_url_key'
]);
select indexes_are('email_verification_code', array[
'email_verification_code_pkey',
'email_verification_code_user_id_key'
]);
select indexes_are('image', array[
'image_pkey',
'image_original_hash_key'
]);
select indexes_are('image_version', array[
'image_version_pkey'
]);
select indexes_are('maintainer', array[
'maintainer_pkey',
'maintainer_email_key'
]);
select indexes_are('notification', array[
'notification_pkey',
'notification_not_processed_idx',
'notification_package_id_package_version_key'
]);
select indexes_are('organization', array[
'organization_pkey',
'organization_name_key'
]);
select indexes_are('package', array[
'package_pkey',
'package_package_kind_id_chart_repository_id_name_key',
@ -170,10 +213,27 @@ select indexes_are('package__maintainer', array[
select indexes_are('package_kind', array[
'package_kind_pkey'
]);
select indexes_are('session', array[
'session_pkey'
]);
select indexes_are('snapshot', array[
'snapshot_pkey',
'snapshot_digest_key'
]);
select indexes_are('subscription', array[
'subscription_pkey'
]);
select indexes_are('user', array[
'user_pkey',
'user_alias_key',
'user_email_key'
]);
select indexes_are('user__organization', array[
'user__organization_pkey'
]);
select indexes_are('user_starred_package', array[
'user_starred_package_pkey'
]);
-- Check expected functions exist
select has_function('add_organization');
@ -217,6 +277,14 @@ select has_function('update_chart_repository');
select has_function('get_image');
select has_function('register_image');
select has_function('add_subscription');
select has_function('delete_subscription');
select has_function('get_package_subscriptions');
select has_function('get_subscriptors');
select has_function('get_user_subscriptions');
select has_function('get_pending_notification');
-- Check package kinds exist
select results_eq(
'select * from package_kind',
@ -228,6 +296,16 @@ select results_eq(
'Package kinds should exist'
);
-- Check notification kinds exist
select results_eq(
'select * from notification_kind',
$$ values
(0, 'New package release'),
(1, 'Security alert')
$$,
'Package kinds should exist'
);
-- Finish tests and rollback transaction
select * from finish();
rollback;

View File

@ -10,8 +10,9 @@ import (
// DB defines the methods the database handler must provide.
type DB interface {
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
Begin(ctx context.Context) (pgx.Tx, error)
Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error)
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
}
// EmailSender defines the methods the email sender must provide.

View File

@ -0,0 +1,33 @@
package hub
import (
"context"
"github.com/jackc/pgx/v4"
)
// Notification represents the details of a notification that will be sent to
// a set of subscribers interested on it.
type Notification struct {
NotificationID string `json:"notification_id"`
PackageVersion string `json:"package_version"`
PackageID string `json:"package_id"`
NotificationKind NotificationKind `json:"notification_kind"`
}
// NotificationKind represents the kind of a notification.
type NotificationKind int64
const (
// NewRelease represents a notification for a new package release.
NewRelease NotificationKind = 0
// SecurityAlert represents a notification for a security alert.
SecurityAlert NotificationKind = 1
)
// NotificationManager describes the methods a NotificationManager
// implementation must provide.
type NotificationManager interface {
GetPending(ctx context.Context, tx pgx.Tx) (*Notification, error)
}

View File

@ -4,6 +4,7 @@ import "context"
// GetPackageInput represents the input used to get a specific package.
type GetPackageInput struct {
PackageID string `json:"package_id"`
ChartRepositoryName string `json:"chart_repository_name"`
PackageName string `json:"package_name"`
Version string `json:"version"`
@ -24,31 +25,31 @@ type Maintainer struct {
// Package represents a Kubernetes package.
type Package struct {
PackageID string `json:"package_id"`
Kind PackageKind `json:"kind"`
Name string `json:"name"`
NormalizedName string `json:"normalized_name"`
LogoURL string `json:"logo_url"`
LogoImageID string `json:"logo_image_id"`
Stars int `json:"stars"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Keywords []string `json:"keywords"`
HomeURL string `json:"home_url"`
Readme string `json:"readme"`
Links []*Link `json:"links"`
Data map[string]interface{} `json:"data"`
Version string `json:"version"`
AvailableVersions []string `json:"available_versions"`
AppVersion string `json:"app_version"`
Digest string `json:"digest"`
Deprecated bool `json:"deprecated"`
Maintainers []*Maintainer `json:"maintainers"`
UserID string `json:"user_id"`
UserAlias string `json:"user_alias"`
OrganizationID string `json:"organization_id"`
OrganizationName string `json:"organization_name"`
ChartRepository *ChartRepository `json:"chart_repository"`
PackageID string `json:"package_id"`
Kind PackageKind `json:"kind"`
Name string `json:"name"`
NormalizedName string `json:"normalized_name"`
LogoURL string `json:"logo_url"`
LogoImageID string `json:"logo_image_id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Keywords []string `json:"keywords"`
HomeURL string `json:"home_url"`
Readme string `json:"readme"`
Links []*Link `json:"links"`
Data map[string]interface{} `json:"data"`
Version string `json:"version"`
AvailableVersions []string `json:"available_versions"`
AppVersion string `json:"app_version"`
Digest string `json:"digest"`
Deprecated bool `json:"deprecated"`
Maintainers []*Maintainer `json:"maintainers"`
UserID string `json:"user_id"`
UserAlias string `json:"user_alias"`
OrganizationID string `json:"organization_id"`
OrganizationName string `json:"organization_name"`
OrganizationDisplayName string `json:"organization_display_name"`
ChartRepository *ChartRepository `json:"chart_repository"`
}
// PackageKind represents the kind of a given package.
@ -68,6 +69,7 @@ const (
// PackageManager describes the methods a PackageManager implementation must
// provide.
type PackageManager interface {
Get(ctx context.Context, input *GetPackageInput) (*Package, error)
GetJSON(ctx context.Context, input *GetPackageInput) ([]byte, error)
GetStarredByUserJSON(ctx context.Context) ([]byte, error)
GetStarsJSON(ctx context.Context, packageID string) ([]byte, error)

View File

@ -0,0 +1,21 @@
package hub
import "context"
// Subscription represents a user's subscription to receive notifications about
// a given package.
type Subscription struct {
UserID string `json:"user_id"`
PackageID string `json:"package_id"`
NotificationKind NotificationKind `json:"notification_kind"`
}
// SubscriptionManager describes the methods a SubscriptionManager
// implementation must provide.
type SubscriptionManager interface {
Add(ctx context.Context, s *Subscription) error
Delete(ctx context.Context, s *Subscription) error
GetByPackageJSON(ctx context.Context, packageID string) ([]byte, error)
GetByUserJSON(ctx context.Context) ([]byte, error)
GetSubscriptors(ctx context.Context, packageID string, notificationKind NotificationKind) ([]*User, error)
}

View File

@ -0,0 +1,72 @@
package notification
import (
"context"
"sync"
"github.com/artifacthub/hub/internal/hub"
"github.com/spf13/viper"
)
const (
defaultNumWorkers = 2
)
// Services is a wrapper around several internal services used to handle
// notifications deliveries.
type Services struct {
DB hub.DB
ES hub.EmailSender
NotificationManager hub.NotificationManager
SubscriptionManager hub.SubscriptionManager
PackageManager hub.PackageManager
}
// Dispatcher handles a group of workers in charge of delivering notifications.
type Dispatcher struct {
numWorkers int
workers []*Worker
}
// NewDispatcher creates a new Dispatcher instance.
func NewDispatcher(cfg *viper.Viper, svc *Services, opts ...func(d *Dispatcher)) *Dispatcher {
d := &Dispatcher{
numWorkers: defaultNumWorkers,
}
for _, o := range opts {
o(d)
}
baseURL := cfg.GetString("server.baseURL")
d.workers = make([]*Worker, 0, d.numWorkers)
for i := 0; i < d.numWorkers; i++ {
d.workers = append(d.workers, NewWorker(svc, baseURL))
}
return d
}
// WithNumWorkers allows providing a specific number of workers for a
// Dispatcher instance.
func WithNumWorkers(n int) func(d *Dispatcher) {
return func(d *Dispatcher) {
d.numWorkers = n
}
}
// Run starts the workers and lets them run until the dispatcher is asked to
// stop via the context provided.
func (d *Dispatcher) Run(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
// Start workers
wwg := &sync.WaitGroup{}
wctx, stopWorkers := context.WithCancel(context.Background())
for _, w := range d.workers {
wwg.Add(1)
go w.Run(wctx, wwg)
}
// Stop workers when dispatcher is asked to stop
<-ctx.Done()
stopWorkers()
wwg.Wait()
}

View File

@ -0,0 +1,31 @@
package notification
import (
"context"
"sync"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
)
func TestDispatcher(t *testing.T) {
// Setup dispatcher
cfg := viper.New()
cfg.Set("server.baseURL", "http://localhost:8000")
d := NewDispatcher(cfg, nil, WithNumWorkers(0))
// Run it
ctx, stopDispatcher := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go d.Run(ctx, &wg)
// Check it stops as expected when asked to do so
stopDispatcher()
assert.Eventually(t, func() bool {
wg.Wait()
return true
}, 2*time.Second, 100*time.Millisecond)
}

View File

@ -0,0 +1,31 @@
package notification
import (
"context"
"encoding/json"
"github.com/artifacthub/hub/internal/hub"
"github.com/jackc/pgx/v4"
)
// Manager provides an API to manage notifications.
type Manager struct{}
// NewManager creates a new Manager instance.
func NewManager() *Manager {
return &Manager{}
}
// GetPending returns a pending notification to be delivered if available.
func (m *Manager) GetPending(ctx context.Context, tx pgx.Tx) (*hub.Notification, error) {
query := "select get_pending_notification()"
var dataJSON []byte
if err := tx.QueryRow(ctx, query).Scan(&dataJSON); err != nil {
return nil, err
}
var n *hub.Notification
if err := json.Unmarshal(dataJSON, &n); err != nil {
return nil, err
}
return n, nil
}

View File

@ -0,0 +1,51 @@
package notification
import (
"context"
"testing"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/tests"
"github.com/stretchr/testify/assert"
)
func TestGetPending(t *testing.T) {
dbQuery := "select get_pending_notification()"
ctx := context.Background()
t.Run("database error", func(t *testing.T) {
tx := &tests.TXMock{}
tx.On("QueryRow", dbQuery).Return(nil, tests.ErrFakeDatabaseFailure)
m := NewManager()
dataJSON, err := m.GetPending(ctx, tx)
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
assert.Nil(t, dataJSON)
tx.AssertExpectations(t)
})
t.Run("database query succeeded", func(t *testing.T) {
expectedNotification := &hub.Notification{
NotificationID: "00000000-0000-0000-0000-000000000001",
PackageVersion: "1.0.0",
PackageID: "00000000-0000-0000-0000-000000000001",
NotificationKind: hub.NewRelease,
}
tx := &tests.TXMock{}
tx.On("QueryRow", dbQuery).Return([]byte(`
{
"notification_id": "00000000-0000-0000-0000-000000000001",
"package_version": "1.0.0",
"package_id": "00000000-0000-0000-0000-000000000001",
"notification_kind": 0
}
`), nil)
m := NewManager()
n, err := m.GetPending(context.Background(), tx)
assert.NoError(t, err)
assert.Equal(t, expectedNotification, n)
tx.AssertExpectations(t)
})
}

View File

@ -0,0 +1,21 @@
package notification
import (
"context"
"github.com/artifacthub/hub/internal/hub"
"github.com/jackc/pgx/v4"
"github.com/stretchr/testify/mock"
)
// ManagerMock is a mock implementation of the NotificationManager interface.
type ManagerMock struct {
mock.Mock
}
// GetPending implements the NotificationManager interface.
func (m *ManagerMock) GetPending(ctx context.Context, tx pgx.Tx) (*hub.Notification, error) {
args := m.Called(ctx, tx)
data, _ := args.Get(0).(*hub.Notification)
return data, args.Error(1)
}

View File

@ -0,0 +1,177 @@
package notification
import "html/template"
var newReleaseEmailTmpl = template.Must(template.New("").Parse(`
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>{{ .name }} new release</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
</style>
</head>
<body class="" style="background-color: #f4f4f4; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f4f4f4;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">{{ .name }} version {{ .version }} released</span>
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px; border-top: 7px solid #659DBD;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; text-align: center;">
<img style="margin: 30px;" width="40px" height="40px" src="{{ .baseURL }}{{ if .logoImageID }}/image/{{ .logoImageID }}@1x{{ else }}/static/media/kubernetes_grey.svg{{ end }}">
<h2 style="color: #39596c; font-family: sans-serif; margin: 0; Margin-bottom: 15px;"><img style="margin-right: 5px; margin-bottom: -2px;" height="18px" src="{{ .baseURL }}/static/media/{{ if eq .kind 1 }}falco{{ else if eq .kind 2 }}opa{{ else }}helm{{ end }}.svg">{{ .name }}</h2>
<h4 style="color: #1c2c35; font-family: sans-serif; margin: 0; Margin-bottom: 15px;">{{ .publisher }} </h4>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 30px;">Version <b>{{ .version }}</b> has been released</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" style="width: 100%; border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
<tbody>
<tr>
<td style="font-family: sans-serif; font-size: 14px; border-radius: 5px; vertical-align: top;"><div style="text-align: center;"> <a href="{{ .baseURL}}{{ .packagePath }}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #39596C; border: solid 1px #39596C; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; border-color: #39596C;">View in Artifact Hub</a> </div></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; font-size: 11px; color: #545454; padding-bottom: 30px; padding-top: 10px;">
<p style="color: #545454; font-size: 11px; text-decoration: none;">Or you can copy-paste this link: <span style="color: #545454; background-color: #ffffff;">{{ .baseURL}}{{ .packagePath }}</span></p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 10px; color: #545454; text-align: center;">
<p style="color: #545454; font-size: 10px; text-align: center; text-decoration: none;">Didn't subscribe to Artifact Hub notifications for {{ .name }} package? You can unsubscribe <a href="{{ .baseURL }}/user/subscriptions" target="_blank" style="text-decoration: underline; color: #545454;">here</a>.</p>
</td>
</tr>
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #39596C; text-align: center;">
<a href="{{ .baseURL }}" style="color: #39596C; font-size: 12px; text-align: center; text-decoration: none;">© Artifact Hub</a>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
`))

View File

@ -0,0 +1,157 @@
package notification
import (
"bytes"
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/artifacthub/hub/internal/email"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/util"
"github.com/jackc/pgx/v4"
"github.com/rs/zerolog/log"
)
const (
pauseOnEmptyQueue = 1 * time.Minute
pauseOnError = 1 * time.Second
)
// Worker is in charge of delivering pending notifications to their intended
// recipients.
type Worker struct {
svc *Services
baseURL string
}
// NewWorker creates a new Worker instance.
func NewWorker(
svc *Services,
baseURL string,
) *Worker {
return &Worker{
svc: svc,
baseURL: baseURL,
}
}
// Run is the main loop of the worker. It calls deliverNotification periodically
// until it's asked to stop via the context provided.
func (w *Worker) Run(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
for {
err := w.deliverNotification(ctx)
switch err {
case nil:
select {
case <-ctx.Done():
return
default:
}
case pgx.ErrNoRows:
select {
case <-time.After(pauseOnEmptyQueue):
case <-ctx.Done():
return
}
default:
select {
case <-time.After(pauseOnError):
case <-ctx.Done():
return
}
}
}
}
// deliverNotification gets a pending notification from the database and
// delivers it.
func (w *Worker) deliverNotification(ctx context.Context) error {
return util.DBTransact(ctx, w.svc.DB, func(tx pgx.Tx) error {
n, err := w.svc.NotificationManager.GetPending(ctx, tx)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
log.Error().Err(err).Msg("error getting pending notification")
}
return err
}
rcpts, err := w.svc.SubscriptionManager.GetSubscriptors(ctx, n.PackageID, n.NotificationKind)
if err != nil {
log.Error().Err(err).Msg("error getting notification subscriptors")
return err
}
if len(rcpts) == 0 {
return nil
}
emailData, err := w.prepareEmailData(ctx, n)
if err != nil {
log.Error().Err(err).Msg("error preparing email data")
return err
}
for _, u := range rcpts {
emailData.To = u.Email
if err := w.svc.ES.SendEmail(emailData); err != nil {
log.Error().
Err(err).
Str("notificationID", n.NotificationID).
Str("email", u.Email).
Msg("error sending notification email")
}
}
return nil
})
}
// prepareEmailData prepares the content of the notification email.
func (w *Worker) prepareEmailData(ctx context.Context, n *hub.Notification) (*email.Data, error) {
var subject string
var emailBody bytes.Buffer
switch n.NotificationKind {
case hub.NewRelease:
p, err := w.svc.PackageManager.Get(ctx, &hub.GetPackageInput{PackageID: n.PackageID})
if err != nil {
return nil, err
}
subject = fmt.Sprintf("%s version %s released", p.Name, p.Version)
publisher := p.OrganizationName
if publisher == "" {
publisher = p.UserAlias
}
if p.ChartRepository != nil {
publisher += "/" + p.ChartRepository.Name
}
var packagePath string
switch p.Kind {
case hub.Chart:
packagePath = fmt.Sprintf("/package/chart/%s/%s", p.ChartRepository.Name, p.NormalizedName)
case hub.Falco:
packagePath = fmt.Sprintf("/package/falco/%s", p.NormalizedName)
case hub.OPA:
packagePath = fmt.Sprintf("/package/opa/%s", p.NormalizedName)
}
data := map[string]interface{}{
"publisher": publisher,
"kind": p.Kind,
"name": p.Name,
"version": n.PackageVersion,
"baseURL": w.baseURL,
"logoImageID": p.LogoImageID,
"packagePath": packagePath,
}
if err := newReleaseEmailTmpl.Execute(&emailBody, data); err != nil {
return nil, err
}
}
return &email.Data{
Subject: subject,
Body: emailBody.Bytes(),
}, nil
}
// - Publisher (/ Chart repo)

View File

@ -0,0 +1,189 @@
package notification
import (
"context"
"errors"
"os"
"sync"
"testing"
"time"
"github.com/artifacthub/hub/internal/email"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/pkg"
"github.com/artifacthub/hub/internal/subscription"
"github.com/artifacthub/hub/internal/tests"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var errFake = errors.New("fake error for tests")
func TestMain(m *testing.M) {
zerolog.SetGlobalLevel(zerolog.Disabled)
os.Exit(m.Run())
}
func TestWorker(t *testing.T) {
t.Run("error getting pending notification", func(t *testing.T) {
sw := newServicesWrapper()
sw.db.On("Begin", mock.Anything).Return(sw.tx, nil)
sw.tx.On("Rollback", mock.Anything).Return(nil)
sw.nm.On("GetPending", mock.Anything, mock.Anything).Return(nil, errFake)
w := NewWorker(sw.svc, "baseURL")
go w.Run(sw.ctx, sw.wg)
sw.assertExpectations(t)
})
t.Run("error getting notification subscriptors", func(t *testing.T) {
sw := newServicesWrapper()
sw.db.On("Begin", mock.Anything).Return(sw.tx, nil)
sw.tx.On("Rollback", mock.Anything).Return(nil)
sw.nm.On("GetPending", mock.Anything, mock.Anything).Return(&hub.Notification{
PackageID: "packageID",
NotificationKind: hub.NewRelease,
}, nil)
sw.sm.On("GetSubscriptors", mock.Anything, "packageID", hub.NewRelease).Return(nil, errFake)
w := NewWorker(sw.svc, "baseURL")
go w.Run(sw.ctx, sw.wg)
sw.assertExpectations(t)
})
t.Run("no subscriptors found", func(t *testing.T) {
sw := newServicesWrapper()
sw.db.On("Begin", mock.Anything).Return(sw.tx, nil)
sw.tx.On("Commit", mock.Anything).Return(nil)
sw.nm.On("GetPending", mock.Anything, mock.Anything).Return(&hub.Notification{
PackageID: "packageID",
NotificationKind: hub.NewRelease,
}, nil)
sw.sm.On("GetSubscriptors", mock.Anything, "packageID", hub.NewRelease).Return([]*hub.User{}, nil)
w := NewWorker(sw.svc, "baseURL")
go w.Run(sw.ctx, sw.wg)
sw.assertExpectations(t)
})
t.Run("error preparing email data", func(t *testing.T) {
sw := newServicesWrapper()
sw.db.On("Begin", mock.Anything).Return(sw.tx, nil)
sw.tx.On("Rollback", mock.Anything).Return(nil)
sw.nm.On("GetPending", mock.Anything, mock.Anything).Return(&hub.Notification{
PackageID: "packageID",
NotificationKind: hub.NewRelease,
}, nil)
sw.sm.On("GetSubscriptors", mock.Anything, "packageID", hub.NewRelease).Return([]*hub.User{
{
Email: "user1@email.com",
},
}, nil)
sw.pm.On("Get", mock.Anything, mock.Anything).Return(nil, errFake)
w := NewWorker(sw.svc, "baseURL")
go w.Run(sw.ctx, sw.wg)
sw.assertExpectations(t)
})
t.Run("error sending email", func(t *testing.T) {
sw := newServicesWrapper()
sw.db.On("Begin", mock.Anything).Return(sw.tx, nil)
sw.tx.On("Rollback", mock.Anything).Return(nil)
sw.nm.On("GetPending", mock.Anything, mock.Anything).Return(&hub.Notification{
PackageID: "packageID",
NotificationKind: hub.NewRelease,
}, nil)
sw.sm.On("GetSubscriptors", mock.Anything, "packageID", hub.NewRelease).Return([]*hub.User{
{
Email: "user1@email.com",
},
}, nil)
sw.pm.On("Get", mock.Anything, mock.Anything).Return(nil, errFake)
sw.es.On("SendEmail", mock.Anything).Return(errFake)
w := NewWorker(sw.svc, "baseURL")
go w.Run(sw.ctx, sw.wg)
sw.assertExpectations(t)
})
t.Run("notification delivered successfully", func(t *testing.T) {
sw := newServicesWrapper()
sw.db.On("Begin", mock.Anything).Return(sw.tx, nil)
sw.tx.On("Rollback", mock.Anything).Return(nil)
sw.nm.On("GetPending", mock.Anything, mock.Anything).Return(&hub.Notification{
PackageID: "packageID",
NotificationKind: hub.NewRelease,
}, nil)
sw.sm.On("GetSubscriptors", mock.Anything, "packageID", hub.NewRelease).Return([]*hub.User{
{
Email: "user1@email.com",
},
}, nil)
sw.pm.On("Get", mock.Anything, mock.Anything).Return(nil, errFake)
sw.es.On("SendEmail", mock.Anything).Return(nil)
w := NewWorker(sw.svc, "baseURL")
go w.Run(sw.ctx, sw.wg)
sw.assertExpectations(t)
})
}
type servicesWrapper struct {
ctx context.Context
stopWorker context.CancelFunc
wg *sync.WaitGroup
db *tests.DBMock
tx *tests.TXMock
es *email.SenderMock
nm *ManagerMock
sm *subscription.ManagerMock
pm *pkg.ManagerMock
svc *Services
}
func newServicesWrapper() *servicesWrapper {
// Context and wait group used for Worker.Run()
ctx, stopWorker := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
db := &tests.DBMock{}
tx := &tests.TXMock{}
es := &email.SenderMock{}
nm := &ManagerMock{}
sm := &subscription.ManagerMock{}
pm := &pkg.ManagerMock{}
return &servicesWrapper{
ctx: ctx,
stopWorker: stopWorker,
wg: &wg,
db: db,
tx: tx,
es: es,
nm: nm,
sm: sm,
pm: pm,
svc: &Services{
DB: db,
ES: es,
NotificationManager: nm,
SubscriptionManager: sm,
PackageManager: pm,
},
}
}
func (sw *servicesWrapper) assertExpectations(t *testing.T) {
sw.stopWorker()
assert.Eventually(t, func() bool {
sw.wg.Wait()
return true
}, 2*time.Second, 100*time.Millisecond)
sw.nm.AssertExpectations(t)
sw.sm.AssertExpectations(t)
sw.pm.AssertExpectations(t)
}

View File

@ -187,6 +187,14 @@ func (m *Manager) DeleteMember(ctx context.Context, orgName, userAlias string) e
return err
}
// GetByUserJSON returns the organizations the user doing the request belongs
// to as a json object.
func (m *Manager) GetByUserJSON(ctx context.Context) ([]byte, error) {
query := "select get_user_organizations($1::uuid)"
userID := ctx.Value(hub.UserIDKey).(string)
return m.dbQueryJSON(ctx, query, userID)
}
// GetJSON returns the organization requested as a json object.
func (m *Manager) GetJSON(ctx context.Context, orgName string) ([]byte, error) {
// Validate input
@ -199,14 +207,6 @@ func (m *Manager) GetJSON(ctx context.Context, orgName string) ([]byte, error) {
return m.dbQueryJSON(ctx, query, orgName)
}
// GetByUserJSON returns the organizations the user doing the request belongs
// to as a json object.
func (m *Manager) GetByUserJSON(ctx context.Context) ([]byte, error) {
query := "select get_user_organizations($1::uuid)"
userID := ctx.Value(hub.UserIDKey).(string)
return m.dbQueryJSON(ctx, query, userID)
}
// GetMembersJSON returns the members of the provided organization as a json
// object.
func (m *Manager) GetMembersJSON(ctx context.Context, orgName string) ([]byte, error) {

View File

@ -354,38 +354,6 @@ func TestDeleteMember(t *testing.T) {
})
}
func TestGetJSON(t *testing.T) {
dbQuery := `select get_organization($1::text)`
t.Run("invalid input", func(t *testing.T) {
m := NewManager(nil, nil)
_, err := m.GetJSON(context.Background(), "")
assert.True(t, errors.Is(err, ErrInvalidInput))
})
t.Run("database query succeeded", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, "orgName").Return([]byte("dataJSON"), nil)
m := NewManager(db, nil)
dataJSON, err := m.GetJSON(context.Background(), "orgName")
assert.NoError(t, err)
assert.Equal(t, []byte("dataJSON"), dataJSON)
db.AssertExpectations(t)
})
t.Run("database error", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, "orgName").Return(nil, tests.ErrFakeDatabaseFailure)
m := NewManager(db, nil)
dataJSON, err := m.GetJSON(context.Background(), "orgName")
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
assert.Nil(t, dataJSON)
db.AssertExpectations(t)
})
}
func TestGetByUserJSON(t *testing.T) {
dbQuery := `select get_user_organizations($1::uuid)`
ctx := context.WithValue(context.Background(), hub.UserIDKey, "userID")
@ -420,6 +388,38 @@ func TestGetByUserJSON(t *testing.T) {
})
}
func TestGetJSON(t *testing.T) {
dbQuery := `select get_organization($1::text)`
t.Run("invalid input", func(t *testing.T) {
m := NewManager(nil, nil)
_, err := m.GetJSON(context.Background(), "")
assert.True(t, errors.Is(err, ErrInvalidInput))
})
t.Run("database query succeeded", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, "orgName").Return([]byte("dataJSON"), nil)
m := NewManager(db, nil)
dataJSON, err := m.GetJSON(context.Background(), "orgName")
assert.NoError(t, err)
assert.Equal(t, []byte("dataJSON"), dataJSON)
db.AssertExpectations(t)
})
t.Run("database error", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, "orgName").Return(nil, tests.ErrFakeDatabaseFailure)
m := NewManager(db, nil)
dataJSON, err := m.GetJSON(context.Background(), "orgName")
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
assert.Nil(t, dataJSON)
db.AssertExpectations(t)
})
}
func TestGetMembersJSON(t *testing.T) {
dbQuery := `select get_organization_members($1::uuid, $2::text)`
ctx := context.WithValue(context.Background(), hub.UserIDKey, "userID")

View File

@ -32,11 +32,24 @@ func NewManager(db hub.DB) *Manager {
}
}
// Get returns the package identified by the input provided.
func (m *Manager) Get(ctx context.Context, input *hub.GetPackageInput) (*hub.Package, error) {
dataJSON, err := m.GetJSON(ctx, input)
if err != nil {
return nil, err
}
p := &hub.Package{}
if err := json.Unmarshal(dataJSON, &p); err != nil {
return nil, err
}
return p, nil
}
// GetJSON returns the package identified by the input provided as a json
// object. The json object is built by the database.
func (m *Manager) GetJSON(ctx context.Context, input *hub.GetPackageInput) ([]byte, error) {
// Validate input
if input.PackageName == "" {
if input.PackageID == "" && input.PackageName == "" {
return nil, fmt.Errorf("%w: %s", ErrInvalidInput, "package name not provided")
}

View File

@ -11,6 +11,138 @@ import (
"github.com/stretchr/testify/mock"
)
func TestGet(t *testing.T) {
dbQuery := "select get_package($1::jsonb)"
t.Run("invalid input", func(t *testing.T) {
m := NewManager(nil)
_, err := m.Get(context.Background(), &hub.GetPackageInput{})
assert.True(t, errors.Is(err, ErrInvalidInput))
})
t.Run("database error", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, mock.Anything, mock.Anything).Return(nil, tests.ErrFakeDatabaseFailure)
m := NewManager(db)
p, err := m.Get(context.Background(), &hub.GetPackageInput{PackageName: "pkg1"})
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
assert.Nil(t, p)
db.AssertExpectations(t)
})
t.Run("database query succeeded", func(t *testing.T) {
expectedPackage := &hub.Package{
PackageID: "00000000-0000-0000-0000-000000000001",
Kind: hub.Chart,
Name: "Package 1",
NormalizedName: "package-1",
LogoImageID: "00000000-0000-0000-0000-000000000001",
DisplayName: "Package 1",
Description: "description",
Keywords: []string{"kw1", "kw2"},
HomeURL: "home_url",
Readme: "readme-version-1.0.0",
Links: []*hub.Link{
{
Name: "link1",
URL: "https://link1",
},
{
Name: "link2",
URL: "https://link2",
},
},
Data: map[string]interface{}{
"key": "value",
},
Version: "1.0.0",
AvailableVersions: []string{"0.0.9", "1.0.0"},
AppVersion: "12.1.0",
Digest: "digest-package1-1.0.0",
Deprecated: true,
Maintainers: []*hub.Maintainer{
{
Name: "name1",
Email: "email1",
},
{
Name: "name2",
Email: "email2",
},
},
UserAlias: "user1",
OrganizationName: "org1",
OrganizationDisplayName: "Organization 1",
ChartRepository: &hub.ChartRepository{
ChartRepositoryID: "00000000-0000-0000-0000-000000000001",
Name: "repo1",
DisplayName: "Repo 1",
URL: "https://repo1.com",
},
}
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, mock.Anything, mock.Anything).Return([]byte(`
{
"package_id": "00000000-0000-0000-0000-000000000001",
"kind": 0,
"name": "Package 1",
"normalized_name": "package-1",
"logo_image_id": "00000000-0000-0000-0000-000000000001",
"display_name": "Package 1",
"description": "description",
"keywords": ["kw1", "kw2"],
"home_url": "home_url",
"readme": "readme-version-1.0.0",
"links": [
{
"name": "link1",
"url": "https://link1"
},
{
"name": "link2",
"url": "https://link2"
}
],
"data": {
"key": "value"
},
"version": "1.0.0",
"available_versions": ["0.0.9", "1.0.0"],
"app_version": "12.1.0",
"digest": "digest-package1-1.0.0",
"deprecated": true,
"maintainers": [
{
"name": "name1",
"email": "email1"
},
{
"name": "name2",
"email": "email2"
}
],
"user_alias": "user1",
"organization_name": "org1",
"organization_display_name": "Organization 1",
"chart_repository": {
"chart_repository_id": "00000000-0000-0000-0000-000000000001",
"name": "repo1",
"display_name": "Repo 1",
"url": "https://repo1.com"
}
}
`), nil)
m := NewManager(db)
p, err := m.Get(context.Background(), &hub.GetPackageInput{PackageName: "package-1"})
assert.NoError(t, err)
assert.Equal(t, expectedPackage, p)
db.AssertExpectations(t)
})
}
func TestGetJSON(t *testing.T) {
dbQuery := "select get_package($1::jsonb)"

View File

@ -12,6 +12,13 @@ type ManagerMock struct {
mock.Mock
}
// Get implements the PackageManager interface.
func (m *ManagerMock) Get(ctx context.Context, input *hub.GetPackageInput) (*hub.Package, error) {
args := m.Called(ctx, input)
data, _ := args.Get(0).(*hub.Package)
return data, args.Error(1)
}
// GetJSON implements the PackageManager interface.
func (m *ManagerMock) GetJSON(ctx context.Context, input *hub.GetPackageInput) ([]byte, error) {
args := m.Called(ctx, input)

View File

@ -0,0 +1,120 @@
package subscription
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/artifacthub/hub/internal/hub"
"github.com/satori/uuid"
)
var (
// ErrInvalidInput indicates that the input provided is not valid.
ErrInvalidInput = errors.New("invalid input")
)
// Manager provides an API to manage subscriptions.
type Manager struct {
db hub.DB
}
// NewManager creates a new Manager instance.
func NewManager(db hub.DB) *Manager {
return &Manager{
db: db,
}
}
// Add adds the provided subscription to the database.
func (m *Manager) Add(ctx context.Context, s *hub.Subscription) error {
userID := ctx.Value(hub.UserIDKey).(string)
s.UserID = userID
if err := validateSubscription(s); err != nil {
return err
}
query := "select add_subscription($1::jsonb)"
sJSON, _ := json.Marshal(s)
_, err := m.db.Exec(ctx, query, sJSON)
return err
}
// Delete removes a subscription from the database.
func (m *Manager) Delete(ctx context.Context, s *hub.Subscription) error {
userID := ctx.Value(hub.UserIDKey).(string)
s.UserID = userID
if err := validateSubscription(s); err != nil {
return err
}
query := "select delete_subscription($1::jsonb)"
sJSON, _ := json.Marshal(s)
_, err := m.db.Exec(ctx, query, sJSON)
return err
}
// GetByPackageJSON returns the subscriptions the user has for a given package
// as json array of objects.
func (m *Manager) GetByPackageJSON(ctx context.Context, packageID string) ([]byte, error) {
userID := ctx.Value(hub.UserIDKey).(string)
if _, err := uuid.FromString(packageID); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidInput, "invalid package id")
}
query := "select get_package_subscriptions($1::uuid, $2::uuid)"
var dataJSON []byte
if err := m.db.QueryRow(ctx, query, userID, packageID).Scan(&dataJSON); err != nil {
return nil, err
}
return dataJSON, nil
}
// GetByUserJSON returns all the subscriptions of the user doing the request as
// as json array of objects.
func (m *Manager) GetByUserJSON(ctx context.Context) ([]byte, error) {
userID := ctx.Value(hub.UserIDKey).(string)
query := "select get_user_subscriptions($1::uuid)"
var dataJSON []byte
if err := m.db.QueryRow(ctx, query, userID).Scan(&dataJSON); err != nil {
return nil, err
}
return dataJSON, nil
}
// GetSubscriptors returns the users subscribed to a package to receive certain
// kind of notifications.
func (m *Manager) GetSubscriptors(
ctx context.Context,
packageID string,
notificationKind hub.NotificationKind,
) ([]*hub.User, error) {
if _, err := uuid.FromString(packageID); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidInput, "invalid package id")
}
if notificationKind != hub.NewRelease {
return nil, fmt.Errorf("%w: %s", ErrInvalidInput, "invalid notification kind")
}
query := "select get_subscriptors($1::uuid, $2::integer)"
var dataJSON []byte
err := m.db.QueryRow(ctx, query, packageID, notificationKind).Scan(&dataJSON)
if err != nil {
return nil, err
}
var subscriptors []*hub.User
if err := json.Unmarshal(dataJSON, &subscriptors); err != nil {
return nil, err
}
return subscriptors, nil
}
// validateSubscription checks if the subscription provided is valid to be used
// as input for some database functions calls.
func validateSubscription(s *hub.Subscription) error {
if _, err := uuid.FromString(s.PackageID); err != nil {
return fmt.Errorf("%w: %s", ErrInvalidInput, "invalid package id")
}
if s.NotificationKind != hub.NewRelease {
return fmt.Errorf("%w: %s", ErrInvalidInput, "invalid notification kind")
}
return nil
}

View File

@ -0,0 +1,299 @@
package subscription
import (
"context"
"errors"
"testing"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const (
userID = "00000000-0000-0000-0000-000000000001"
packageID = "00000000-0000-0000-0000-000000000001"
)
func TestAdd(t *testing.T) {
dbQuery := "select add_subscription($1::jsonb)"
ctx := context.WithValue(context.Background(), hub.UserIDKey, userID)
t.Run("user id not found in ctx", func(t *testing.T) {
m := NewManager(nil)
assert.Panics(t, func() {
_ = m.Add(context.Background(), &hub.Subscription{})
})
})
t.Run("invalid input", func(t *testing.T) {
testCases := []struct {
errMsg string
s *hub.Subscription
}{
{
"invalid package id",
&hub.Subscription{
PackageID: "invalid",
},
},
{
"invalid notification kind",
&hub.Subscription{
PackageID: packageID,
NotificationKind: hub.NotificationKind(5),
},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.errMsg, func(t *testing.T) {
m := NewManager(nil)
err := m.Add(ctx, tc.s)
assert.True(t, errors.Is(err, ErrInvalidInput))
assert.Contains(t, err.Error(), tc.errMsg)
})
}
})
s := &hub.Subscription{
PackageID: packageID,
NotificationKind: hub.NewRelease,
}
t.Run("database error", func(t *testing.T) {
db := &tests.DBMock{}
db.On("Exec", dbQuery, mock.Anything).Return(tests.ErrFakeDatabaseFailure)
m := NewManager(db)
err := m.Add(ctx, s)
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
db.AssertExpectations(t)
})
t.Run("database query succeeded", func(t *testing.T) {
db := &tests.DBMock{}
db.On("Exec", dbQuery, mock.Anything).Return(nil)
m := NewManager(db)
err := m.Add(ctx, s)
assert.NoError(t, err)
db.AssertExpectations(t)
})
}
func TestDelete(t *testing.T) {
dbQuery := "select delete_subscription($1::jsonb)"
ctx := context.WithValue(context.Background(), hub.UserIDKey, userID)
t.Run("user id not found in ctx", func(t *testing.T) {
m := NewManager(nil)
assert.Panics(t, func() {
_ = m.Delete(context.Background(), &hub.Subscription{})
})
})
t.Run("invalid input", func(t *testing.T) {
testCases := []struct {
errMsg string
s *hub.Subscription
}{
{
"invalid package id",
&hub.Subscription{
PackageID: "invalid",
},
},
{
"invalid notification kind",
&hub.Subscription{
PackageID: packageID,
NotificationKind: hub.NotificationKind(5),
},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.errMsg, func(t *testing.T) {
m := NewManager(nil)
err := m.Delete(ctx, tc.s)
assert.True(t, errors.Is(err, ErrInvalidInput))
assert.Contains(t, err.Error(), tc.errMsg)
})
}
})
s := &hub.Subscription{
PackageID: packageID,
NotificationKind: hub.NewRelease,
}
t.Run("database error", func(t *testing.T) {
db := &tests.DBMock{}
db.On("Exec", dbQuery, mock.Anything).Return(tests.ErrFakeDatabaseFailure)
m := NewManager(db)
err := m.Delete(ctx, s)
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
db.AssertExpectations(t)
})
t.Run("database query succeeded", func(t *testing.T) {
db := &tests.DBMock{}
db.On("Exec", dbQuery, mock.Anything).Return(nil)
m := NewManager(db)
err := m.Delete(ctx, s)
assert.NoError(t, err)
db.AssertExpectations(t)
})
}
func TestGetByPackageJSON(t *testing.T) {
dbQuery := "select get_package_subscriptions($1::uuid, $2::uuid)"
ctx := context.WithValue(context.Background(), hub.UserIDKey, userID)
t.Run("user id not found in ctx", func(t *testing.T) {
m := NewManager(nil)
assert.Panics(t, func() {
_, _ = m.GetByPackageJSON(context.Background(), "")
})
})
t.Run("invalid input", func(t *testing.T) {
m := NewManager(nil)
_, err := m.GetByPackageJSON(ctx, "")
assert.True(t, errors.Is(err, ErrInvalidInput))
assert.Contains(t, err.Error(), "invalid package id")
})
t.Run("database query succeeded", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, userID, packageID).Return([]byte("dataJSON"), nil)
m := NewManager(db)
dataJSON, err := m.GetByPackageJSON(ctx, packageID)
assert.NoError(t, err)
assert.Equal(t, []byte("dataJSON"), dataJSON)
db.AssertExpectations(t)
})
t.Run("database error", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, userID, packageID).Return(nil, tests.ErrFakeDatabaseFailure)
m := NewManager(db)
dataJSON, err := m.GetByPackageJSON(ctx, packageID)
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
assert.Nil(t, dataJSON)
db.AssertExpectations(t)
})
}
func TestGetByUserJSON(t *testing.T) {
dbQuery := "select get_user_subscriptions($1::uuid)"
ctx := context.WithValue(context.Background(), hub.UserIDKey, userID)
t.Run("user id not found in ctx", func(t *testing.T) {
m := NewManager(nil)
assert.Panics(t, func() {
_, _ = m.GetByUserJSON(context.Background())
})
})
t.Run("database query succeeded", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, userID).Return([]byte("dataJSON"), nil)
m := NewManager(db)
dataJSON, err := m.GetByUserJSON(ctx)
assert.NoError(t, err)
assert.Equal(t, []byte("dataJSON"), dataJSON)
db.AssertExpectations(t)
})
t.Run("database error", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, userID).Return(nil, tests.ErrFakeDatabaseFailure)
m := NewManager(db)
dataJSON, err := m.GetByUserJSON(ctx)
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
assert.Nil(t, dataJSON)
db.AssertExpectations(t)
})
}
func TestGetSubscriptors(t *testing.T) {
dbQuery := "select get_subscriptors($1::uuid, $2::integer)"
t.Run("invalid input", func(t *testing.T) {
testCases := []struct {
errMsg string
packageID string
notificationKind hub.NotificationKind
}{
{
"invalid package id",
"invalid",
0,
},
{
"invalid notification kind",
packageID,
hub.NotificationKind(5),
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.errMsg, func(t *testing.T) {
m := NewManager(nil)
dataJSON, err := m.GetSubscriptors(context.Background(), tc.packageID, tc.notificationKind)
assert.True(t, errors.Is(err, ErrInvalidInput))
assert.Contains(t, err.Error(), tc.errMsg)
assert.Nil(t, dataJSON)
})
}
})
t.Run("database error", func(t *testing.T) {
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, packageID, hub.NotificationKind(0)).Return(nil, tests.ErrFakeDatabaseFailure)
m := NewManager(db)
subscriptors, err := m.GetSubscriptors(context.Background(), packageID, hub.NewRelease)
assert.Equal(t, tests.ErrFakeDatabaseFailure, err)
assert.Nil(t, subscriptors)
db.AssertExpectations(t)
})
t.Run("database query succeeded", func(t *testing.T) {
expectedSubscriptors := []*hub.User{
{
Email: "user1@email.com",
},
{
Email: "user2@email.com",
},
}
db := &tests.DBMock{}
db.On("QueryRow", dbQuery, packageID, hub.NotificationKind(0)).Return([]byte(`
[
{
"email": "user1@email.com"
},
{
"email": "user2@email.com"
}
]
`), nil)
m := NewManager(db)
subscriptors, err := m.GetSubscriptors(context.Background(), packageID, hub.NewRelease)
assert.NoError(t, err)
assert.Equal(t, expectedSubscriptors, subscriptors)
db.AssertExpectations(t)
})
}

View File

@ -0,0 +1,50 @@
package subscription
import (
"context"
"github.com/artifacthub/hub/internal/hub"
"github.com/stretchr/testify/mock"
)
// ManagerMock is a mock implementation of the SubscriptionManager interface.
type ManagerMock struct {
mock.Mock
}
// Add implements the SubscriptionManager interface.
func (m *ManagerMock) Add(ctx context.Context, s *hub.Subscription) error {
args := m.Called(ctx, s)
return args.Error(0)
}
// Delete implements the SubscriptionManager interface.
func (m *ManagerMock) Delete(ctx context.Context, s *hub.Subscription) error {
args := m.Called(ctx, s)
return args.Error(0)
}
// GetByPackageJSON implements the SubscriptionManager interface.
func (m *ManagerMock) GetByPackageJSON(ctx context.Context, packageID string) ([]byte, error) {
args := m.Called(ctx, packageID)
data, _ := args.Get(0).([]byte)
return data, args.Error(1)
}
// GetByUserJSON implements the SubscriptionManager interface.
func (m *ManagerMock) GetByUserJSON(ctx context.Context) ([]byte, error) {
args := m.Called(ctx)
data, _ := args.Get(0).([]byte)
return data, args.Error(1)
}
// GetByUserJSON implements the SubscriptionManager interface.
func (m *ManagerMock) GetSubscriptors(
ctx context.Context,
packageID string,
notificationKind hub.NotificationKind,
) ([]*hub.User, error) {
args := m.Called(ctx, packageID, notificationKind)
data, _ := args.Get(0).([]*hub.User)
return data, args.Error(1)
}

View File

@ -17,6 +17,19 @@ type DBMock struct {
mock.Mock
}
// Begin implements the DB interface.
func (m *DBMock) Begin(ctx context.Context) (pgx.Tx, error) {
args := m.Called(ctx)
tx, _ := args.Get(0).(pgx.Tx)
return tx, args.Error(1)
}
// Exec implements the DB interface.
func (m *DBMock) Exec(ctx context.Context, query string, params ...interface{}) (pgconn.CommandTag, error) {
args := m.Called(append([]interface{}{query}, params...)...)
return nil, args.Error(0)
}
// QueryRow implements the DB interface.
func (m *DBMock) QueryRow(ctx context.Context, query string, params ...interface{}) pgx.Row {
args := m.Called(append([]interface{}{query}, params...)...)
@ -32,10 +45,89 @@ func (m *DBMock) QueryRow(ctx context.Context, query string, params ...interface
return rowMock
}
// Exec implements the DB interface.
func (m *DBMock) Exec(ctx context.Context, query string, params ...interface{}) (pgconn.CommandTag, error) {
// TXMock is a mock implementation of the pgx.Tx interface.
type TXMock struct {
mock.Mock
}
// Begin implements the pgx.Tx interface.
func (m *TXMock) Begin(ctx context.Context) (pgx.Tx, error) {
// NOTE: not used
return nil, nil
}
// Commit implements the pgx.Tx interface.
func (m *TXMock) Commit(ctx context.Context) error {
args := m.Called(ctx)
return args.Error(0)
}
// Conn implements the pgx.Tx interface.
func (m *TXMock) Conn() *pgx.Conn {
// NOTE: not used
return nil
}
// CopyFrom implements the pgx.Tx interface.
func (m *TXMock) CopyFrom(
ctx context.Context,
tableName pgx.Identifier,
columnNames []string,
rowSrc pgx.CopyFromSource,
) (int64, error) {
// NOTE: not used
return 0, nil
}
// Exec implements the pgx.Tx interface.
func (m *TXMock) Exec(ctx context.Context, query string, params ...interface{}) (pgconn.CommandTag, error) {
// NOTE: not used
return nil, nil
}
// LargeObjects implements the pgx.Tx interface.
func (m *TXMock) LargeObjects() pgx.LargeObjects {
// NOTE: not used
return pgx.LargeObjects{}
}
// Prepare implements the pgx.Tx interface.
func (m *TXMock) Prepare(ctx context.Context, name, sql string) (*pgconn.StatementDescription, error) {
// NOTE: not used
return nil, nil
}
// QueryRow implements the pgx.Tx interface.
func (m *TXMock) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
// NOTE: not used
return nil, nil
}
// QueryRow implements the pgx.Tx interface.
func (m *TXMock) QueryRow(ctx context.Context, query string, params ...interface{}) pgx.Row {
args := m.Called(append([]interface{}{query}, params...)...)
return nil, args.Error(0)
rowMock := &RowMock{
err: args.Error(1),
}
switch v := args.Get(0).(type) {
case []interface{}:
rowMock.data = v
case interface{}:
rowMock.data = []interface{}{args.Get(0)}
}
return rowMock
}
// Rollback implements the pgx.Tx interface.
func (m *TXMock) Rollback(ctx context.Context) error {
args := m.Called(ctx)
return args.Error(0)
}
// SendBatch implements the pgx.Tx interface.
func (m *TXMock) SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults {
// NOTE: not used
return nil
}
// RowMock is a mock implementation of the pgx.Row interface.

View File

@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/artifacthub/hub/internal/hub"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/log/zerologadapter"
"github.com/jackc/pgx/v4/pgxpool"
@ -40,3 +41,24 @@ func SetupDB(cfg *viper.Viper) (*pgxpool.Pool, error) {
return pool, nil
}
// DBTransact is a helper function that wraps some database transactions taking
// care of committing and rolling back when needed.
func DBTransact(ctx context.Context, db hub.DB, txFunc func(pgx.Tx) error) (err error) {
tx, err := db.Begin(ctx)
if err != nil {
return
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback(ctx)
panic(p)
} else if err != nil {
_ = tx.Rollback(ctx)
} else {
err = tx.Commit(ctx)
}
}()
err = txFunc(tx)
return err
}