Notifications repository + service (#4565)

* Notifications repository

* feat: notifications repo + service

* fix: remove use of notifications/notifications.go

* Update persistence/notificationsrepository/notificationsrepository.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: remove unused method

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Gabe Kangas
2025-11-21 15:34:01 -08:00
committed by GitHub
co-authored by Copilot
parent 96b566664e
commit fea4339e94
9 changed files with 802 additions and 201 deletions
@@ -0,0 +1,6 @@
package notificationsrepository
const (
// BrowserPushNotification represents a push notification for a browser.
BrowserPushNotification = "BROWSER_PUSH_NOTIFICATION"
)
@@ -0,0 +1,231 @@
package notificationsrepository
import (
"testing"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
)
var (
integrationTestDatastore *data.Datastore
integrationRepo NotificationsRepository
)
func init() {
// Use the shared test datastore from the main test file
// This ensures we're using the same database setup
integrationTestDatastore = testDatastore
if integrationTestDatastore != nil {
integrationRepo = New(integrationTestDatastore)
}
}
func TestIntegrationSetup(t *testing.T) {
// This test ensures integration setup works properly
// The actual setup is done in the init function below
if integrationTestDatastore == nil {
integrationTestDatastore = data.GetDatastore()
Setup(integrationTestDatastore)
integrationRepo = New(integrationTestDatastore)
}
if integrationRepo == nil {
t.Error("Integration repository should be initialized")
}
}
func TestBrowserPushSetupIntegration(t *testing.T) {
// Test that browser push keys are generated during setup
configRepo := configrepository.Get()
pubKey, err := configRepo.GetBrowserPushPublicKey()
if err != nil {
t.Errorf("Should be able to get browser push public key: %v", err)
}
privKey, err := configRepo.GetBrowserPushPrivateKey()
if err != nil {
t.Errorf("Should be able to get browser push private key: %v", err)
}
// Keys should be generated during setup
if pubKey == "" || privKey == "" {
t.Error("Browser push keys should be generated during setup")
}
}
func TestBrowserPushConfigurationIntegration(t *testing.T) {
configRepo := configrepository.Get()
// Test that browser push is enabled by default
browserConfig := configRepo.GetBrowserPushConfig()
if !browserConfig.Enabled {
t.Error("Browser push should be enabled by default")
}
// Test that initial notification configuration flag is set
hasConfigured := configRepo.GetHasPerformedInitialNotificationsConfig()
if !hasConfigured {
t.Error("Should have performed initial notifications configuration")
}
}
func TestDiscordConfigurationIntegration(t *testing.T) {
configRepo := configrepository.Get()
// Test Discord configuration setup
discordConfig := models.DiscordConfiguration{
Enabled: true,
Webhook: "https://discord.com/api/webhooks/test",
GoLiveMessage: "Test stream is live!",
}
err := configRepo.SetDiscordConfig(discordConfig)
if err != nil {
t.Errorf("Failed to set Discord configuration: %v", err)
}
// Test with disabled Discord
disabledConfig := models.DiscordConfiguration{
Enabled: false,
Webhook: "",
}
err = configRepo.SetDiscordConfig(disabledConfig)
if err != nil {
t.Errorf("Failed to disable Discord configuration: %v", err)
}
}
func TestNotificationWorkflowIntegration(t *testing.T) {
channel := BrowserPushNotification
destination := "integration-test-endpoint"
// Add a notification
err := integrationRepo.AddNotification(channel, destination)
if err != nil {
t.Errorf("Failed to add notification: %v", err)
}
// Verify it was added
destinations, err := integrationRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get notification destinations: %v", err)
}
found := false
for _, dest := range destinations {
if dest == destination {
found = true
break
}
}
if !found {
t.Error("Notification destination should be found after adding")
}
// Clean up
err = integrationRepo.RemoveNotificationForChannel(channel, destination)
if err != nil {
t.Errorf("Failed to remove notification: %v", err)
}
// Verify removal
destinationsAfter, err := integrationRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get notification destinations after removal: %v", err)
}
for _, dest := range destinationsAfter {
if dest == destination {
t.Error("Notification destination should be removed")
}
}
}
func TestRepositoryGetSingleton(t *testing.T) {
// Test that Get() returns the same instance
repo1 := Get()
repo2 := Get()
if repo1 != repo2 {
t.Error("Get() should return the same singleton instance")
}
// Test that it implements the interface
var _ NotificationsRepository = repo1
}
func TestDatabaseTransactionIntegrity(t *testing.T) {
channel := "TRANSACTION_TEST_CHANNEL"
destinations := []string{"dest1", "dest2", "dest3"}
// Add multiple notifications
for _, dest := range destinations {
err := integrationRepo.AddNotification(channel, dest)
if err != nil {
t.Errorf("Failed to add notification %s: %v", dest, err)
}
}
// Verify all were added
retrievedDests, err := integrationRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to retrieve destinations: %v", err)
}
if len(retrievedDests) != len(destinations) {
t.Errorf("Expected %d destinations, got %d", len(destinations), len(retrievedDests))
}
// Remove them one by one and verify state consistency
for i, dest := range destinations {
err := integrationRepo.RemoveNotificationForChannel(channel, dest)
if err != nil {
t.Errorf("Failed to remove notification %s: %v", dest, err)
}
remaining, err := integrationRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get remaining destinations: %v", err)
}
expectedRemaining := len(destinations) - (i + 1)
if len(remaining) != expectedRemaining {
t.Errorf("Expected %d remaining destinations, got %d", expectedRemaining, len(remaining))
}
}
}
func TestErrorHandling(t *testing.T) {
// Test with empty channel
err := integrationRepo.AddNotification("", "test-destination")
if err != nil {
// Empty channel might be allowed - this documents the behavior
t.Logf("Empty channel behavior: %v", err)
}
// Test with empty destination
err = integrationRepo.AddNotification("TEST_CHANNEL", "")
if err != nil {
// Empty destination might be allowed - this documents the behavior
t.Logf("Empty destination behavior: %v", err)
}
// Test removing from empty channel
err = integrationRepo.RemoveNotificationForChannel("", "test-destination")
if err != nil {
t.Logf("Remove from empty channel behavior: %v", err)
}
// Test getting destinations for empty channel
destinations, err := integrationRepo.GetNotificationDestinationsForChannel("")
if err != nil {
t.Errorf("Getting destinations for empty channel should not error: %v", err)
}
if len(destinations) > 0 {
t.Logf("Empty channel returned %d destinations", len(destinations))
}
}
@@ -0,0 +1,110 @@
package notificationsrepository
import (
"context"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/persistence/tables"
"github.com/owncast/owncast/notifications/browser"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
type NotificationsRepository interface {
AddNotification(channel, destination string) error
RemoveNotificationForChannel(channel, destination string) error
GetNotificationDestinationsForChannel(channel string) ([]string, error)
}
// SqlNotificationsRepository handles database operations for notifications.
type SqlNotificationsRepository struct {
datastore *data.Datastore
}
// NOTE: This is temporary during the transition period.
var temporaryGlobalInstance NotificationsRepository
// Get will return the notifications repository.
func Get() NotificationsRepository {
if temporaryGlobalInstance == nil {
i := New(data.GetDatastore())
temporaryGlobalInstance = i
}
return temporaryGlobalInstance
}
// Setup will perform any pre-use setup for the notifier.
func Setup(datastore *data.Datastore) {
tables.CreateNotificationsTable(datastore.DB)
initializeBrowserPushIfNeeded()
}
func initializeBrowserPushIfNeeded() {
configRepository := configrepository.Get()
pubKey, _ := configRepository.GetBrowserPushPublicKey()
privKey, _ := configRepository.GetBrowserPushPrivateKey()
// We need browser push keys so people can register for pushes.
if pubKey == "" || privKey == "" {
browserPrivateKey, browserPublicKey, err := browser.GenerateBrowserPushKeys()
if err != nil {
log.Errorln("unable to initialize browser push notification keys", err)
}
if err := configRepository.SetBrowserPushPrivateKey(browserPrivateKey); err != nil {
log.Errorln("unable to set browser push private key", err)
}
if err := configRepository.SetBrowserPushPublicKey(browserPublicKey); err != nil {
log.Errorln("unable to set browser push public key", err)
}
}
// Enable browser push notifications by default.
if !configRepository.GetHasPerformedInitialNotificationsConfig() {
_ = configRepository.SetBrowserPushConfig(models.BrowserNotificationConfiguration{Enabled: true, GoLiveMessage: config.GetDefaults().FederationGoLiveMessage})
_ = configRepository.SetHasPerformedInitialNotificationsConfig(true)
}
}
// New creates a new instance of the NotificationsRepository.
func New(datastore *data.Datastore) NotificationsRepository {
return &SqlNotificationsRepository{
datastore: datastore,
}
}
// AddNotification saves a new user notification destination.
func (n *SqlNotificationsRepository) AddNotification(channel, destination string) error {
return n.datastore.GetQueries().AddNotification(context.Background(), db.AddNotificationParams{
Channel: channel,
Destination: destination,
})
}
// RemoveNotificationForChannel removes a notification destination.
func (n *SqlNotificationsRepository) RemoveNotificationForChannel(channel, destination string) error {
log.Debugln("Removing notification for channel", channel)
return n.datastore.GetQueries().RemoveNotificationDestinationForChannel(context.Background(), db.RemoveNotificationDestinationForChannelParams{
Channel: channel,
Destination: destination,
})
}
// GetNotificationDestinationsForChannel will return a collection of
// destinations to notify for a given channel.
func (n *SqlNotificationsRepository) GetNotificationDestinationsForChannel(channel string) ([]string, error) {
result, err := n.datastore.GetQueries().GetNotificationDestinationsForChannel(context.Background(), channel)
if err != nil {
return nil, errors.Wrap(err, "unable to query notification destinations for channel "+channel)
}
return result, nil
}
@@ -0,0 +1,323 @@
package notificationsrepository
import (
"fmt"
"testing"
"github.com/owncast/owncast/core/data"
)
var (
testDatastore *data.Datastore
testRepo NotificationsRepository
)
func TestMain(m *testing.M) {
// Create an in-memory database for testing
if err := data.SetupPersistence(":memory:"); err != nil {
panic(err)
}
// Get the shared datastore instance
testDatastore = data.GetDatastore()
// Setup the notifications repository
Setup(testDatastore)
testRepo = New(testDatastore)
// Run tests
m.Run()
}
func TestAddNotification(t *testing.T) {
channel := "TEST_CHANNEL_ADD_UNIQUE"
destination := "test-destination-add-unique-123"
// Test adding a notification
err := testRepo.AddNotification(channel, destination)
if err != nil {
t.Errorf("Failed to add notification: %v", err)
}
// Verify the notification was added
destinations, err := testRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get notification destinations: %v", err)
}
if len(destinations) != 1 {
t.Errorf("Expected 1 destination, got %d", len(destinations))
}
if destinations[0] != destination {
t.Errorf("Expected destination %s, got %s", destination, destinations[0])
}
}
func TestAddMultipleNotifications(t *testing.T) {
channel := "MULTI_TEST_CHANNEL"
destinations := []string{"dest-1", "dest-2", "dest-3"}
// Add multiple notifications
for _, dest := range destinations {
err := testRepo.AddNotification(channel, dest)
if err != nil {
t.Errorf("Failed to add notification for destination %s: %v", dest, err)
}
}
// Verify all notifications were added
retrievedDests, err := testRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get notification destinations: %v", err)
}
if len(retrievedDests) != len(destinations) {
t.Errorf("Expected %d destinations, got %d", len(destinations), len(retrievedDests))
}
// Check that all destinations are present
for _, expectedDest := range destinations {
found := false
for _, retrievedDest := range retrievedDests {
if retrievedDest == expectedDest {
found = true
break
}
}
if !found {
t.Errorf("Expected destination %s not found in retrieved destinations", expectedDest)
}
}
}
func TestRemoveNotificationForChannel(t *testing.T) {
channel := "REMOVE_TEST_CHANNEL"
destination1 := "remove-dest-1"
destination2 := "remove-dest-2"
// Add two notifications
err := testRepo.AddNotification(channel, destination1)
if err != nil {
t.Errorf("Failed to add notification: %v", err)
}
err = testRepo.AddNotification(channel, destination2)
if err != nil {
t.Errorf("Failed to add notification: %v", err)
}
// Verify both were added
destinations, err := testRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get notification destinations: %v", err)
}
if len(destinations) != 2 {
t.Errorf("Expected 2 destinations before removal, got %d", len(destinations))
}
// Remove one notification
err = testRepo.RemoveNotificationForChannel(channel, destination1)
if err != nil {
t.Errorf("Failed to remove notification: %v", err)
}
// Verify only one remains
destinations, err = testRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get notification destinations after removal: %v", err)
}
if len(destinations) != 1 {
t.Errorf("Expected 1 destination after removal, got %d", len(destinations))
}
if destinations[0] != destination2 {
t.Errorf("Expected remaining destination %s, got %s", destination2, destinations[0])
}
}
func TestGetNotificationDestinationsForNonExistentChannel(t *testing.T) {
channel := "NON_EXISTENT_CHANNEL"
destinations, err := testRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get notification destinations for non-existent channel: %v", err)
}
if len(destinations) != 0 {
t.Errorf("Expected 0 destinations for non-existent channel, got %d", len(destinations))
}
}
func TestRemoveNonExistentNotification(t *testing.T) {
channel := "NON_EXISTENT_REMOVE_CHANNEL"
destination := "non-existent-destination"
// Try to remove a notification that doesn't exist
err := testRepo.RemoveNotificationForChannel(channel, destination)
if err != nil {
t.Errorf("Removing non-existent notification should not return error, got: %v", err)
}
}
func TestBrowserPushNotificationConstant(t *testing.T) {
// Test that the constant is defined and has the expected value
if BrowserPushNotification == "" {
t.Error("BrowserPushNotification constant should not be empty")
}
expectedValue := "BROWSER_PUSH_NOTIFICATION"
if BrowserPushNotification != expectedValue {
t.Errorf("Expected BrowserPushNotification to be %s, got %s", expectedValue, BrowserPushNotification)
}
}
func TestNotificationRepositoryInterface(t *testing.T) {
// Test that our implementation satisfies the interface
var _ NotificationsRepository = &SqlNotificationsRepository{}
// Test that we can get the repository instance
repo := Get()
if repo == nil {
t.Error("Get() should return a non-nil repository instance")
}
// Test that New creates a valid repository
newRepo := New(testDatastore)
if newRepo == nil {
t.Error("New() should return a non-nil repository instance")
}
}
func TestAddDuplicateNotification(t *testing.T) {
channel := "DUPLICATE_TEST_CHANNEL"
destination := "duplicate-dest"
// Add the same notification twice
err := testRepo.AddNotification(channel, destination)
if err != nil {
t.Errorf("Failed to add notification first time: %v", err)
}
err = testRepo.AddNotification(channel, destination)
if err != nil {
t.Errorf("Failed to add duplicate notification: %v", err)
}
// Check how many destinations we have (should handle duplicates gracefully)
destinations, err := testRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
t.Errorf("Failed to get notification destinations: %v", err)
}
// The behavior for duplicates depends on database constraints
// This test documents the current behavior
if len(destinations) == 0 {
t.Error("Should have at least one destination even with duplicates")
}
}
func TestChannelIsolation(t *testing.T) {
channel1 := "CHANNEL_1"
channel2 := "CHANNEL_2"
destination1 := "dest-for-channel-1"
destination2 := "dest-for-channel-2"
// Add notifications to different channels
err := testRepo.AddNotification(channel1, destination1)
if err != nil {
t.Errorf("Failed to add notification to channel1: %v", err)
}
err = testRepo.AddNotification(channel2, destination2)
if err != nil {
t.Errorf("Failed to add notification to channel2: %v", err)
}
// Verify channel isolation
destinations1, err := testRepo.GetNotificationDestinationsForChannel(channel1)
if err != nil {
t.Errorf("Failed to get destinations for channel1: %v", err)
}
destinations2, err := testRepo.GetNotificationDestinationsForChannel(channel2)
if err != nil {
t.Errorf("Failed to get destinations for channel2: %v", err)
}
// Each channel should only have its own destination
if len(destinations1) != 1 || destinations1[0] != destination1 {
t.Errorf("Channel1 should only have destination1, got: %v", destinations1)
}
if len(destinations2) != 1 || destinations2[0] != destination2 {
t.Errorf("Channel2 should only have destination2, got: %v", destinations2)
}
// Remove from one channel shouldn't affect the other
err = testRepo.RemoveNotificationForChannel(channel1, destination1)
if err != nil {
t.Errorf("Failed to remove notification from channel1: %v", err)
}
// Verify channel2 is unaffected
destinations2After, err := testRepo.GetNotificationDestinationsForChannel(channel2)
if err != nil {
t.Errorf("Failed to get destinations for channel2 after removal: %v", err)
}
if len(destinations2After) != 1 || destinations2After[0] != destination2 {
t.Errorf("Channel2 should still have destination2 after channel1 removal, got: %v", destinations2After)
}
}
// Benchmark tests
func BenchmarkAddNotification(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
channel := fmt.Sprintf("BENCH_CHANNEL_%d", i)
destination := fmt.Sprintf("bench_destination_%d", i)
err := testRepo.AddNotification(channel, destination)
if err != nil {
b.Fatalf("Failed to add notification: %v", err)
}
}
}
func BenchmarkGetNotificationDestinationsForChannel(b *testing.B) {
// Setup test data
channel := "BENCH_GET_CHANNEL"
for i := 0; i < 100; i++ {
destination := fmt.Sprintf("bench_get_destination_%d", i)
testRepo.AddNotification(channel, destination)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := testRepo.GetNotificationDestinationsForChannel(channel)
if err != nil {
b.Fatalf("Failed to get notification destinations: %v", err)
}
}
}
func BenchmarkRemoveNotificationForChannel(b *testing.B) {
// Setup test data
channel := "BENCH_REMOVE_CHANNEL"
destinations := make([]string, b.N)
for i := 0; i < b.N; i++ {
destination := fmt.Sprintf("bench_remove_destination_%d", i)
destinations[i] = destination
testRepo.AddNotification(channel, destination)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := testRepo.RemoveNotificationForChannel(channel, destinations[i])
if err != nil {
b.Fatalf("Failed to remove notification: %v", err)
}
}
}