feat: add notification events handling and MQTT integration
- Implemented notification event publishing with a new NotificationEventPublisher interface. - Created a noopNotificationEventPublisher for testing purposes. - Added functionality to publish notification created events via MQTT. - Introduced a new stream event publisher for handling job logs and updates. - Added database migration for popup_ads table. - Created tests for notification events and popup ads functionality. - Established MQTT connection and publishing helpers for event messages.
This commit is contained in:
96
internal/service/__test__/service_notification_mqtt_test.go
Normal file
96
internal/service/__test__/service_notification_mqtt_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
appv1 "stream.api/internal/api/proto/app/v1"
|
||||
"stream.api/internal/database/model"
|
||||
)
|
||||
|
||||
type publishedNotificationEvent struct {
|
||||
notification *model.Notification
|
||||
}
|
||||
|
||||
type fakeNotificationEventPublisher struct {
|
||||
events []publishedNotificationEvent
|
||||
}
|
||||
|
||||
func (f *fakeNotificationEventPublisher) PublishNotificationCreated(_ context.Context, notification *model.Notification) error {
|
||||
copyNotification := *notification
|
||||
f.events = append(f.events, publishedNotificationEvent{notification: ©Notification})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExecutePaymentFlow_PublishesNotificationEvent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
services := newTestAppServices(t, db)
|
||||
publisher := &fakeNotificationEventPublisher{}
|
||||
services.notificationEvents = publisher
|
||||
|
||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Role: ptrString("USER")})
|
||||
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 10, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
|
||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
|
||||
|
||||
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{
|
||||
UserID: user.ID,
|
||||
Plan: &plan,
|
||||
TermMonths: 1,
|
||||
PaymentMethod: paymentMethodTopup,
|
||||
TopupAmount: ptrFloat64(5),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executePaymentFlow() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("executePaymentFlow() result is nil")
|
||||
}
|
||||
if len(publisher.events) != 1 {
|
||||
t.Fatalf("published events = %d, want 1", len(publisher.events))
|
||||
}
|
||||
if publisher.events[0].notification == nil || publisher.events[0].notification.Type != "billing.subscription" {
|
||||
t.Fatalf("published notification = %#v", publisher.events[0].notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopupWallet_PublishesNotificationEvent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
services := newTestAppServices(t, db)
|
||||
publisher := &fakeNotificationEventPublisher{}
|
||||
services.notificationEvents = publisher
|
||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||
|
||||
_, err := (&paymentsAppService{appServices: services}).TopupWallet(testActorIncomingContext(user.ID, "USER"), &appv1.TopupWalletRequest{Amount: 12})
|
||||
if err != nil {
|
||||
t.Fatalf("TopupWallet() error = %v", err)
|
||||
}
|
||||
if len(publisher.events) != 1 {
|
||||
t.Fatalf("published events = %d, want 1", len(publisher.events))
|
||||
}
|
||||
if publisher.events[0].notification == nil || publisher.events[0].notification.Type != "billing.topup" {
|
||||
t.Fatalf("published notification = %#v", publisher.events[0].notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNotificationCreatedPayload(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
notification := &model.Notification{
|
||||
ID: uuid.NewString(),
|
||||
UserID: uuid.NewString(),
|
||||
Type: "billing.subscription",
|
||||
Title: "Subscription activated",
|
||||
Message: "Your subscription is active.",
|
||||
ActionURL: ptrString("/settings/billing"),
|
||||
ActionLabel: ptrString("Renew plan"),
|
||||
CreatedAt: &now,
|
||||
}
|
||||
payload := BuildNotificationCreatedPayload(notification)
|
||||
if payload.ID != notification.ID || payload.UserID != notification.UserID || payload.Type != notification.Type {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if payload.CreatedAt == "" {
|
||||
t.Fatal("payload created_at should not be empty")
|
||||
}
|
||||
}
|
||||
177
internal/service/__test__/service_popup_ads_test.go
Normal file
177
internal/service/__test__/service_popup_ads_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/grpc/codes"
|
||||
appv1 "stream.api/internal/api/proto/app/v1"
|
||||
"stream.api/internal/database/model"
|
||||
)
|
||||
|
||||
func TestPopupAdsUserFlow(t *testing.T) {
|
||||
t.Run("create list update delete popup ad", func(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
services := newTestAppServices(t, db)
|
||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||
|
||||
startAt := time.Now().UTC().Add(-time.Hour)
|
||||
endAt := time.Now().UTC().Add(2 * time.Hour)
|
||||
createResp, err := services.CreatePopupAd(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePopupAdRequest{
|
||||
Title: "Homepage Campaign",
|
||||
ImageUrl: "https://cdn.example.com/banner.jpg",
|
||||
TargetUrl: "https://example.com/landing",
|
||||
IsActive: ptrBool(true),
|
||||
StartAt: timeToProto(&startAt),
|
||||
EndAt: timeToProto(&endAt),
|
||||
Priority: int32Ptr(5),
|
||||
CloseCooldownMinutes: int32Ptr(90),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePopupAd() error = %v", err)
|
||||
}
|
||||
if createResp.Item == nil || createResp.Item.Title != "Homepage Campaign" {
|
||||
t.Fatalf("CreatePopupAd() unexpected response: %#v", createResp)
|
||||
}
|
||||
|
||||
listResp, err := services.ListPopupAds(testActorIncomingContext(user.ID, "USER"), &appv1.ListPopupAdsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPopupAds() error = %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 {
|
||||
t.Fatalf("ListPopupAds() count = %d, want 1", len(listResp.Items))
|
||||
}
|
||||
|
||||
updateResp, err := services.UpdatePopupAd(testActorIncomingContext(user.ID, "USER"), &appv1.UpdatePopupAdRequest{
|
||||
Id: createResp.Item.Id,
|
||||
Title: "Homepage Campaign v2",
|
||||
ImageUrl: "https://cdn.example.com/banner-v2.jpg",
|
||||
TargetUrl: "https://example.com/landing-v2",
|
||||
IsActive: ptrBool(false),
|
||||
Priority: int32Ptr(8),
|
||||
CloseCooldownMinutes: int32Ptr(30),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePopupAd() error = %v", err)
|
||||
}
|
||||
if updateResp.Item == nil || updateResp.Item.Title != "Homepage Campaign v2" || updateResp.Item.IsActive {
|
||||
t.Fatalf("UpdatePopupAd() unexpected response: %#v", updateResp)
|
||||
}
|
||||
|
||||
items := mustListPopupAdsByUser(t, db, user.ID)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("popup ad count = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].Priority != 8 || items[0].CloseCooldownMinutes != 30 {
|
||||
t.Fatalf("popup ad values = %#v", items[0])
|
||||
}
|
||||
|
||||
_, err = services.DeletePopupAd(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePopupAdRequest{Id: createResp.Item.Id})
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePopupAd() error = %v", err)
|
||||
}
|
||||
items = mustListPopupAdsByUser(t, db, user.ID)
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("popup ad count after delete = %d, want 0", len(items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject invalid schedule", func(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
services := newTestAppServices(t, db)
|
||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||
startAt := time.Now().UTC().Add(time.Hour)
|
||||
endAt := time.Now().UTC()
|
||||
|
||||
_, err := services.CreatePopupAd(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePopupAdRequest{
|
||||
Title: "Invalid",
|
||||
ImageUrl: "https://cdn.example.com/banner.jpg",
|
||||
TargetUrl: "https://example.com/landing",
|
||||
StartAt: timeToProto(&startAt),
|
||||
EndAt: timeToProto(&endAt),
|
||||
})
|
||||
assertGRPCCode(t, err, codes.InvalidArgument)
|
||||
})
|
||||
|
||||
t.Run("get active popup ad picks highest priority valid item", func(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
services := newTestAppServices(t, db)
|
||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||
now := time.Now().UTC()
|
||||
seedTestPopupAd(t, db, model.PopupAd{ID: uuid.NewString(), UserID: user.ID, Title: "inactive", ImageURL: "https://cdn.example.com/1.jpg", TargetURL: "https://example.com/1", IsActive: ptrBool(false), Priority: 99})
|
||||
seedTestPopupAd(t, db, model.PopupAd{ID: uuid.NewString(), UserID: user.ID, Title: "expired", ImageURL: "https://cdn.example.com/2.jpg", TargetURL: "https://example.com/2", IsActive: ptrBool(true), Priority: 50, StartAt: ptrTime(now.Add(-2 * time.Hour)), EndAt: ptrTime(now.Add(-time.Hour))})
|
||||
seedTestPopupAd(t, db, model.PopupAd{ID: uuid.NewString(), UserID: user.ID, Title: "low", ImageURL: "https://cdn.example.com/3.jpg", TargetURL: "https://example.com/3", IsActive: ptrBool(true), Priority: 1, StartAt: ptrTime(now.Add(-time.Hour)), EndAt: ptrTime(now.Add(time.Hour))})
|
||||
winner := seedTestPopupAd(t, db, model.PopupAd{ID: uuid.NewString(), UserID: user.ID, Title: "winner", ImageURL: "https://cdn.example.com/4.jpg", TargetURL: "https://example.com/4", IsActive: ptrBool(true), Priority: 10, StartAt: ptrTime(now.Add(-time.Hour)), EndAt: ptrTime(now.Add(time.Hour)), CloseCooldownMinutes: 15})
|
||||
|
||||
resp, err := services.GetActivePopupAd(testActorIncomingContext(user.ID, "USER"), &appv1.GetActivePopupAdRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivePopupAd() error = %v", err)
|
||||
}
|
||||
if resp.Item == nil || resp.Item.Id != winner.ID {
|
||||
t.Fatalf("GetActivePopupAd() = %#v, want winner %q", resp.Item, winner.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPopupAdsAdminFlow(t *testing.T) {
|
||||
t.Run("admin create list update delete popup ad", func(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
services := newTestAppServices(t, db)
|
||||
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
|
||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||
conn, cleanup := newTestGRPCServer(t, services)
|
||||
defer cleanup()
|
||||
|
||||
client := newAdminClient(conn)
|
||||
createResp, err := client.CreateAdminPopupAd(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPopupAdRequest{
|
||||
UserId: user.ID,
|
||||
Title: "Admin Campaign",
|
||||
ImageUrl: "https://cdn.example.com/admin.jpg",
|
||||
TargetUrl: "https://example.com/admin",
|
||||
IsActive: ptrBool(true),
|
||||
Priority: int32Ptr(7),
|
||||
CloseCooldownMinutes: int32Ptr(45),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAdminPopupAd() error = %v", err)
|
||||
}
|
||||
if createResp.Item == nil || createResp.Item.UserId != user.ID {
|
||||
t.Fatalf("CreateAdminPopupAd() unexpected response: %#v", createResp)
|
||||
}
|
||||
|
||||
listResp, err := client.ListAdminPopupAds(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.ListAdminPopupAdsRequest{UserId: &user.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAdminPopupAds() error = %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 {
|
||||
t.Fatalf("ListAdminPopupAds() count = %d, want 1", len(listResp.Items))
|
||||
}
|
||||
|
||||
updateResp, err := client.UpdateAdminPopupAd(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminPopupAdRequest{
|
||||
Id: createResp.Item.Id,
|
||||
UserId: user.ID,
|
||||
Title: "Admin Campaign v2",
|
||||
ImageUrl: "https://cdn.example.com/admin-v2.jpg",
|
||||
TargetUrl: "https://example.com/admin-v2",
|
||||
IsActive: ptrBool(false),
|
||||
Priority: int32Ptr(11),
|
||||
CloseCooldownMinutes: int32Ptr(10),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAdminPopupAd() error = %v", err)
|
||||
}
|
||||
if updateResp.Item == nil || updateResp.Item.Title != "Admin Campaign v2" || updateResp.Item.IsActive {
|
||||
t.Fatalf("UpdateAdminPopupAd() unexpected response: %#v", updateResp)
|
||||
}
|
||||
|
||||
_, err = client.DeleteAdminPopupAd(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.DeleteAdminPopupAdRequest{Id: createResp.Item.Id})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAdminPopupAd() error = %v", err)
|
||||
}
|
||||
items := mustListPopupAdsByUser(t, db, user.ID)
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("popup ad count after delete = %d, want 0", len(items))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -15,11 +15,12 @@ import (
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
_ "modernc.org/sqlite"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
appv1 "stream.api/internal/api/proto/app/v1"
|
||||
"stream.api/internal/database/model"
|
||||
"stream.api/internal/database/query"
|
||||
"stream.api/internal/middleware"
|
||||
"stream.api/internal/repository"
|
||||
"stream.api/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -74,7 +75,7 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", uuid.NewString())
|
||||
db, err := gorm.Open(sqlite.Dialector{DriverName: "sqlite", DSN: dsn}, &gorm.Config{})
|
||||
db, err := gorm.Open(sqlite.Dialector{DriverName: "sqlite3", DSN: dsn}, &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
@@ -206,6 +207,21 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
encrytion_m3u8 BOOLEAN NOT NULL DEFAULT 1,
|
||||
logo_url TEXT
|
||||
)`,
|
||||
`CREATE TABLE popup_ads (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL,
|
||||
target_url TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
start_at DATETIME,
|
||||
end_at DATETIME,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
close_cooldown_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
)`,
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
t.Fatalf("create test schema: %v", err)
|
||||
@@ -226,9 +242,17 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
|
||||
return &appServices{
|
||||
db: db,
|
||||
logger: testLogger{},
|
||||
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker),
|
||||
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker, nil),
|
||||
// cache: &fakeCache{values: map[string]string{}},
|
||||
googleUserInfoURL: defaultGoogleUserInfoURL,
|
||||
googleUserInfoURL: defaultGoogleUserInfoURL,
|
||||
userRepository: repository.NewUserRepository(db),
|
||||
planRepository: repository.NewPlanRepository(db),
|
||||
paymentRepository: repository.NewPaymentRepository(db),
|
||||
notificationRepo: repository.NewNotificationRepository(db),
|
||||
domainRepository: repository.NewDomainRepository(db),
|
||||
adTemplateRepository: repository.NewAdTemplateRepository(db),
|
||||
popupAdRepository: repository.NewPopupAdRepository(db),
|
||||
playerConfigRepo: repository.NewPlayerConfigRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +268,7 @@ func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, f
|
||||
NotificationsServer: services,
|
||||
DomainsServer: services,
|
||||
AdTemplatesServer: services,
|
||||
PopupAdsServer: services,
|
||||
PlayerConfigsServer: services,
|
||||
PlansServer: services,
|
||||
PaymentsServer: services,
|
||||
@@ -382,4 +407,33 @@ func newAdminClient(conn *grpc.ClientConn) appv1.AdminClient {
|
||||
return appv1.NewAdminClient(conn)
|
||||
}
|
||||
|
||||
func ptrTime(v time.Time) *time.Time { return &v }
|
||||
|
||||
func seedTestPopupAd(t *testing.T, db *gorm.DB, item model.PopupAd) model.PopupAd {
|
||||
t.Helper()
|
||||
if item.IsActive == nil {
|
||||
item.IsActive = ptrBool(true)
|
||||
}
|
||||
if item.CreatedAt == nil {
|
||||
now := time.Now().UTC()
|
||||
item.CreatedAt = &now
|
||||
}
|
||||
if item.CloseCooldownMinutes == 0 {
|
||||
item.CloseCooldownMinutes = 60
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create popup ad: %v", err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func mustListPopupAdsByUser(t *testing.T, db *gorm.DB, userID string) []model.PopupAd {
|
||||
t.Helper()
|
||||
var items []model.PopupAd
|
||||
if err := db.Order("priority DESC, created_at DESC").Find(&items, "user_id = ?", userID).Error; err != nil {
|
||||
t.Fatalf("list popup ads for user %s: %v", userID, err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
var _ logger.Logger = testLogger{}
|
||||
|
||||
Reference in New Issue
Block a user