From 4728744c6125909375478ec5ddae5934f1d7e1f7 Mon Sep 17 00:00:00 2001 From: Daniel Cadenas Date: Fri, 30 Aug 2024 20:18:57 -0300 Subject: [PATCH] Read new aggregated payload from followers server --- internal/fixtures/fixtures.go | 7 + service/adapters/apns/apns.go | 101 ++++++-- service/adapters/apns/apns_mock.go | 2 +- service/adapters/apns/apns_test.go | 243 ++++++++++++++++++ .../gcp/gcp_follow_change_subscriber.go | 6 +- service/adapters/gcp/noop_subscriber.go | 4 +- .../external_follow_change_subscriber.go | 4 +- service/app/app.go | 4 +- service/app/follow_change_puller.go | 13 +- service/domain/follow_change.go | 69 ----- service/domain/follow_change_batch.go | 75 ++++++ service/domain/follow_change_batch_test.go | 110 ++++++++ 12 files changed, 526 insertions(+), 112 deletions(-) create mode 100644 service/adapters/apns/apns_test.go delete mode 100644 service/domain/follow_change.go create mode 100644 service/domain/follow_change_batch.go create mode 100644 service/domain/follow_change_batch_test.go diff --git a/internal/fixtures/fixtures.go b/internal/fixtures/fixtures.go index 65e4b8a..bfb318a 100644 --- a/internal/fixtures/fixtures.go +++ b/internal/fixtures/fixtures.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" "github.com/planetary-social/go-notification-service/internal" "github.com/planetary-social/go-notification-service/service/domain" ) @@ -72,6 +73,12 @@ func SomeHexBytesOfLen(l int) string { return hex.EncodeToString(b) } +func PublicKeyAndNpub() (domain.PublicKey, string) { + pk, _ := SomeKeyPair() + npub, _ := nip19.EncodePublicKey(pk.Hex()) + return pk, npub +} + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func randSeq(n int) string { diff --git a/service/adapters/apns/apns.go b/service/adapters/apns/apns.go index 4f6310e..2d983fe 100644 --- a/service/adapters/apns/apns.go +++ b/service/adapters/apns/apns.go @@ -2,6 +2,7 @@ package apns import ( "encoding/json" + "fmt" "strings" "github.com/boreq/errors" @@ -15,6 +16,8 @@ import ( "github.com/sideshow/apns2/certificate" ) +const MAX_TOTAL_NPUBS = 58 + type Metrics interface { ReportCallToAPNS(statusCode int, err error) } @@ -80,7 +83,7 @@ func (a *APNS) SendNotification(notification notifications.Notification) error { return nil } -func (a *APNS) SendFollowChangeNotification(followChange domain.FollowChange, apnsToken domain.APNSToken) error { +func (a *APNS) SendFollowChangeNotification(followChange domain.FollowChangeBatch, apnsToken domain.APNSToken) error { if apnsToken.Hex() == "" { return errors.New("invalid APNs token") } @@ -113,8 +116,8 @@ func (a *APNS) SendFollowChangeNotification(followChange domain.FollowChange, ap return nil } -func (a *APNS) buildFollowChangeNotification(followChange domain.FollowChange, apnsToken domain.APNSToken) (*apns2.Notification, error) { - payload, err := followChangePayload(followChange) +func (a *APNS) buildFollowChangeNotification(followChange domain.FollowChangeBatch, apnsToken domain.APNSToken) (*apns2.Notification, error) { + payload, err := FollowChangePayload(followChange) if err != nil { return nil, errors.Wrap(err, "error creating a payload") } @@ -131,49 +134,79 @@ func (a *APNS) buildFollowChangeNotification(followChange domain.FollowChange, a return n, nil } -func followChangePayload(followChange domain.FollowChange) ([]byte, error) { +func FollowChangePayload(followChange domain.FollowChangeBatch) ([]byte, error) { + return FollowChangePayloadWithValidation(followChange, true) +} + +func FollowChangePayloadWithValidation(followChange domain.FollowChangeBatch, validate bool) ([]byte, error) { alertMessage := "" - if strings.HasPrefix(followChange.FriendlyFollower, "npub") { - if followChange.ChangeType == "unfollowed" { - alertMessage = "You've been unfollowed!" + totalNpubs := len(followChange.Follows) + len(followChange.Unfollows) + if validate && totalNpubs > MAX_TOTAL_NPUBS { + return nil, errors.New("FollowChangeBatch for followee " + followChange.Followee.Hex() + " has too many npubs (" + fmt.Sprint(totalNpubs) + "). MAX_TOTAL_NPUBS is " + fmt.Sprint(MAX_TOTAL_NPUBS)) + } + + singleChange := totalNpubs == 1 + + if singleChange { + isFollow := len(followChange.Follows) == 1 + if strings.HasPrefix(followChange.FriendlyFollower, "npub") { + if isFollow { + alertMessage = "You have a new follower!" + } else { + alertMessage = "You've been unfollowed!" + } } else { - alertMessage = "You have a new follower!" + if isFollow { + alertMessage = followChange.FriendlyFollower + " is a new follower!" + } else { + alertMessage = followChange.FriendlyFollower + " has unfollowed you!" + } } } else { - if followChange.ChangeType == "unfollowed" { - alertMessage = followChange.FriendlyFollower + " has unfollowed you!" - } else { - alertMessage = followChange.FriendlyFollower + " is a new follower!" - } + alertMessage = fmt.Sprintf("You have %d new followers and %d unfollows!", len(followChange.Follows), len(followChange.Unfollows)) } - followerNpub, err := nip19.EncodePublicKey(followChange.Follower.Hex()) - if err != nil { - return nil, errors.Wrap(err, "error encoding the follower npub") + followeeNpub, error := nip19.EncodePublicKey(followChange.Followee.Hex()) + if error != nil { + return nil, errors.Wrap(error, "error encoding followee npub") } - var follows []string - var unfollows []string - if followChange.ChangeType == "followed" { - follows = append(follows, followerNpub) - } else { - unfollows = append(unfollows, followerNpub) + npubFollows, error := pubkeysToNpubs(followChange.Follows) + if error != nil { + return nil, errors.Wrap(error, "error encoding follow npubs") + } + + npubUnfollows, error := pubkeysToNpubs(followChange.Unfollows) + if error != nil { + return nil, errors.Wrap(error, "error encoding unfollow npubs") } // See https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification + + var data map[string]interface{} + + if singleChange { + data = map[string]interface{}{ + "follows": npubFollows, + "unfollows": npubUnfollows, + "friendlyFollower": followChange.FriendlyFollower, + } + } else { + data = map[string]interface{}{ + "follows": npubFollows, + "unfollows": npubUnfollows, + } + } + payload := map[string]interface{}{ "aps": map[string]interface{}{ "alert": alertMessage, "sound": "default", "badge": 1, - "thread-id": followChange.Followee.Hex(), + "thread-id": followeeNpub, "interruption-level": "passive", }, - "data": map[string]interface{}{ - "follows": follows, - "unfollows": unfollows, - "friendlyFollower": followChange.FriendlyFollower, - }, + "data": data, } payloadBytes, err := json.Marshal(payload) @@ -183,3 +216,15 @@ func followChangePayload(followChange domain.FollowChange) ([]byte, error) { return payloadBytes, nil } + +func pubkeysToNpubs(pubkeys []domain.PublicKey) ([]string, error) { + npubs := make([]string, len(pubkeys)) + for i, pubkey := range pubkeys { + npub, err := nip19.EncodePublicKey(pubkey.Hex()) + if err != nil { + return nil, errors.Wrap(err, "error encoding a public key") + } + npubs[i] = npub + } + return npubs, nil +} diff --git a/service/adapters/apns/apns_mock.go b/service/adapters/apns/apns_mock.go index d2fd538..1fec1df 100644 --- a/service/adapters/apns/apns_mock.go +++ b/service/adapters/apns/apns_mock.go @@ -35,7 +35,7 @@ func (a *APNSMock) SendNotification(notification notifications.Notification) err return nil } -func (a *APNSMock) SendFollowChangeNotification(followChange domain.FollowChange, token domain.APNSToken) error { +func (a *APNSMock) SendFollowChangeNotification(followChange domain.FollowChangeBatch, token domain.APNSToken) error { notification := notifications.Notification{} return a.SendNotification(notification) diff --git a/service/adapters/apns/apns_test.go b/service/adapters/apns/apns_test.go new file mode 100644 index 0000000..54e4ffa --- /dev/null +++ b/service/adapters/apns/apns_test.go @@ -0,0 +1,243 @@ +package apns_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/planetary-social/go-notification-service/internal/fixtures" + "github.com/planetary-social/go-notification-service/service/adapters/apns" + "github.com/planetary-social/go-notification-service/service/domain" + "github.com/stretchr/testify/require" +) + +func TestFollowChangePayload_SingleFollow(t *testing.T) { + pk1, pk1Npub := fixtures.PublicKeyAndNpub() + pk2, pk2Npub := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "npub_someFollower", + Follows: []domain.PublicKey{pk2}, + Unfollows: nil, + } + + payload, err := apns.FollowChangePayload(batch) + require.NoError(t, err) + + expectedAlert := "You have a new follower!" + expectedPayload := map[string]interface{}{ + "aps": map[string]interface{}{ + "alert": expectedAlert, + "sound": "default", + "badge": float64(1), // Convert badge to float64 + "thread-id": pk1Npub, + "interruption-level": "passive", + }, + "data": map[string]interface{}{ + "follows": []interface{}{pk2Npub}, // Use []interface{} + "unfollows": []interface{}{}, + "friendlyFollower": batch.FriendlyFollower, + }, + } + + var actualPayload map[string]interface{} + err = json.Unmarshal(payload, &actualPayload) + require.NoError(t, err) + + require.Equal(t, expectedPayload, actualPayload) +} + +func TestFollowChangePayload_SingleUnfollow(t *testing.T) { + pk1, pk1Npub := fixtures.PublicKeyAndNpub() + pk2, pk2Npub := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "npub_someFollower", + Follows: nil, + Unfollows: []domain.PublicKey{pk2}, + } + + payload, err := apns.FollowChangePayload(batch) + require.NoError(t, err) + + expectedAlert := "You've been unfollowed!" + expectedPayload := map[string]interface{}{ + "aps": map[string]interface{}{ + "alert": expectedAlert, + "sound": "default", + "badge": float64(1), // Convert badge to float64 + "thread-id": pk1Npub, + "interruption-level": "passive", + }, + "data": map[string]interface{}{ + "follows": []interface{}{}, + "unfollows": []interface{}{pk2Npub}, // Use []interface{} + "friendlyFollower": batch.FriendlyFollower, + }, + } + + var actualPayload map[string]interface{} + err = json.Unmarshal(payload, &actualPayload) + require.NoError(t, err) + + require.Equal(t, expectedPayload, actualPayload) +} + +func TestFollowChangePayload_MultipleFollowsUnfollows(t *testing.T) { + pk1, pk1Npub := fixtures.PublicKeyAndNpub() + pk2, pk2Npub := fixtures.PublicKeyAndNpub() + pk3, pk3Npub := fixtures.PublicKeyAndNpub() + pk4, pk4Npub := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "FriendlyUser", + Follows: []domain.PublicKey{pk2, pk3}, + Unfollows: []domain.PublicKey{pk4}, + } + + payload, err := apns.FollowChangePayload(batch) + require.NoError(t, err) + + expectedAlert := "You have 2 new followers and 1 unfollows!" + expectedPayload := map[string]interface{}{ + "aps": map[string]interface{}{ + "alert": expectedAlert, + "sound": "default", + "badge": float64(1), // Convert badge to float64 + "thread-id": pk1Npub, + "interruption-level": "passive", + }, + "data": map[string]interface{}{ + "follows": []interface{}{pk2Npub, pk3Npub}, // Use []interface{} + "unfollows": []interface{}{pk4Npub}, // Use []interface{} + }, + } + + var actualPayload map[string]interface{} + err = json.Unmarshal(payload, &actualPayload) + require.NoError(t, err) + + require.Equal(t, expectedPayload, actualPayload) +} + +func TestFollowChangePayload_SingleFollow_WithFriendlyFollower(t *testing.T) { + pk1, pk1Npub := fixtures.PublicKeyAndNpub() + pk2, pk2Npub := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "John Doe", + Follows: []domain.PublicKey{pk2}, + Unfollows: nil, + } + + payload, err := apns.FollowChangePayload(batch) + require.NoError(t, err) + + expectedAlert := "John Doe is a new follower!" + expectedPayload := map[string]interface{}{ + "aps": map[string]interface{}{ + "alert": expectedAlert, + "sound": "default", + "badge": float64(1), // Convert badge to float64 + "thread-id": pk1Npub, + "interruption-level": "passive", + }, + "data": map[string]interface{}{ + "follows": []interface{}{pk2Npub}, // Use []interface{} + "unfollows": []interface{}{}, + "friendlyFollower": batch.FriendlyFollower, + }, + } + + var actualPayload map[string]interface{} + err = json.Unmarshal(payload, &actualPayload) + require.NoError(t, err) + + require.Equal(t, expectedPayload, actualPayload) +} +func TestFollowChangePayload_Exceeds4096Bytes_With59TotalNpubs(t *testing.T) { + pk1, _ := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "npub_someFollower_wont_be_added", + Follows: []domain.PublicKey{}, + Unfollows: []domain.PublicKey{}, + } + + for i := 0; i < 29; i++ { // 29 follows + follow, _ := fixtures.PublicKeyAndNpub() + batch.Follows = append(batch.Follows, follow) + } + for i := 0; i < 30; i++ { // 30 unfollows + unfollow, _ := fixtures.PublicKeyAndNpub() + batch.Unfollows = append(batch.Unfollows, unfollow) + } + + payload, err := apns.FollowChangePayloadWithValidation(batch, false) + require.NoError(t, err) + + // Ensure 59 is the maximum size we can get + payloadSize := len(payload) + t.Logf("Payload size with 59 total follows and unfollows: %d bytes", payloadSize) + require.True(t, payloadSize > 4096, fmt.Sprintf("Payload size should exceed 4096 bytes, but was %d bytes", payloadSize)) +} + +func TestFollowChangePayload_ValidPayload_With58TotalNpubs_IsValid(t *testing.T) { + pk1, _ := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "npub_someFollower_wont_be_added", + Follows: []domain.PublicKey{}, + Unfollows: []domain.PublicKey{}, + } + + for i := 0; i < 29; i++ { // 29 follows + follow, _ := fixtures.PublicKeyAndNpub() + batch.Follows = append(batch.Follows, follow) + } + for i := 0; i < 29; i++ { // 29 unfollows + unfollow, _ := fixtures.PublicKeyAndNpub() + batch.Unfollows = append(batch.Unfollows, unfollow) + } + + payload, err := apns.FollowChangePayloadWithValidation(batch, true) // With validation + require.NoError(t, err) + + // Ensure 58 is the maximum size we can get + payloadSize := len(payload) + t.Logf("Payload size with 58 total follows and unfollows: %d bytes", payloadSize) + require.True(t, payloadSize <= 4096, fmt.Sprintf("Payload size should be within 4096 bytes, but was %d bytes", payloadSize)) +} + +func TestFollowChangePayload_InvalidPayload_With59TotalNpubs_Fails_With_Validation(t *testing.T) { + pk1, _ := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "npub_someFollower_wont_be_added", + Follows: []domain.PublicKey{}, + Unfollows: []domain.PublicKey{}, + } + + for i := 0; i < 29; i++ { // 29 follows + follow, _ := fixtures.PublicKeyAndNpub() + batch.Follows = append(batch.Follows, follow) + } + for i := 0; i < 30; i++ { // 30 unfollows + unfollow, _ := fixtures.PublicKeyAndNpub() + batch.Unfollows = append(batch.Unfollows, unfollow) + } + + payload, err := apns.FollowChangePayload(batch) // This always validates + require.Error(t, err) + require.Nil(t, payload) + + expectedError := fmt.Sprintf("FollowChangeBatch for followee %s has too many npubs (59). MAX_TOTAL_NPUBS is 58", pk1.Hex()) + require.EqualError(t, err, expectedError) +} diff --git a/service/adapters/gcp/gcp_follow_change_subscriber.go b/service/adapters/gcp/gcp_follow_change_subscriber.go index 0f42335..5b58c71 100644 --- a/service/adapters/gcp/gcp_follow_change_subscriber.go +++ b/service/adapters/gcp/gcp_follow_change_subscriber.go @@ -39,13 +39,13 @@ func NewFollowChangeSubscriber(subscriber *googlecloud.Subscriber, logger waterm return &GCPFollowChangeSubscriber{subscriber: subscriber, logger: logger} } -func (p *GCPFollowChangeSubscriber) Subscribe(ctx context.Context) (<-chan *domain.FollowChange, error) { +func (p *GCPFollowChangeSubscriber) Subscribe(ctx context.Context) (<-chan *domain.FollowChangeBatch, error) { subChan, err := p.subscriber.Subscribe(ctx, googlePubSubFollowChangeTopic) if err != nil { return nil, errors.Wrap(err, "error subscribing") } - ch := make(chan *domain.FollowChange) + ch := make(chan *domain.FollowChangeBatch) go func() { defer close(ch) @@ -55,7 +55,7 @@ func (p *GCPFollowChangeSubscriber) Subscribe(ctx context.Context) (<-chan *doma // We never retry messages so we can ACK immediately. message.Ack() - var payload domain.FollowChange + var payload domain.FollowChangeBatch if err := json.Unmarshal(message.Payload, &payload); err != nil { p.logger.Error("error unmarshaling follow change payload", err, watermill.LogFields{"payload": string(message.Payload)}) continue diff --git a/service/adapters/gcp/noop_subscriber.go b/service/adapters/gcp/noop_subscriber.go index 5e09fa6..26d60de 100644 --- a/service/adapters/gcp/noop_subscriber.go +++ b/service/adapters/gcp/noop_subscriber.go @@ -12,7 +12,7 @@ type NoopSubscriber struct { func NewNoopSubscriber() *NoopSubscriber { return &NoopSubscriber{} } -func (p *NoopSubscriber) Subscribe(ctx context.Context) (<-chan *domain.FollowChange, error) { - ch := make(chan *domain.FollowChange) +func (p *NoopSubscriber) Subscribe(ctx context.Context) (<-chan *domain.FollowChangeBatch, error) { + ch := make(chan *domain.FollowChangeBatch) return ch, nil } diff --git a/service/adapters/mocks/external_follow_change_subscriber.go b/service/adapters/mocks/external_follow_change_subscriber.go index 0816c78..1e2dda0 100644 --- a/service/adapters/mocks/external_follow_change_subscriber.go +++ b/service/adapters/mocks/external_follow_change_subscriber.go @@ -13,7 +13,7 @@ func NewMockExternalFollowChangeSubscriber() *MockExternalFollowChangeSubscriber return &MockExternalFollowChangeSubscriber{} } -func (m MockExternalFollowChangeSubscriber) Subscribe(ctx context.Context) (<-chan *domain.FollowChange, error) { - ch := make(chan *domain.FollowChange) +func (m MockExternalFollowChangeSubscriber) Subscribe(ctx context.Context) (<-chan *domain.FollowChangeBatch, error) { + ch := make(chan *domain.FollowChangeBatch) return ch, nil } diff --git a/service/app/app.go b/service/app/app.go index 8c5a40b..77eaf5d 100644 --- a/service/app/app.go +++ b/service/app/app.go @@ -57,7 +57,7 @@ type ExternalEventPublisher interface { } type ExternalFollowChangeSubscriber interface { - Subscribe(ctx context.Context) (<-chan *domain.FollowChange, error) + Subscribe(ctx context.Context) (<-chan *domain.FollowChangeBatch, error) } type Application struct { @@ -80,7 +80,7 @@ type Queries struct { type APNS interface { SendNotification(notification notifications.Notification) error - SendFollowChangeNotification(followChange domain.FollowChange, apnsToken domain.APNSToken) error + SendFollowChangeNotification(followChange domain.FollowChangeBatch, apnsToken domain.APNSToken) error } type EventOrError struct { diff --git a/service/app/follow_change_puller.go b/service/app/follow_change_puller.go index a9a25eb..0e871ae 100644 --- a/service/app/follow_change_puller.go +++ b/service/app/follow_change_puller.go @@ -8,6 +8,9 @@ import ( "github.com/planetary-social/go-notification-service/internal/logging" ) +// Reads from the follow-change puller, creates FollowChangeBatch types from +// each entry and sends notifications to those users for which we have APNS +// tokens type FollowChangePuller struct { externalFollowChangeSubscriber ExternalFollowChangeSubscriber apns APNS @@ -46,24 +49,24 @@ func (f *FollowChangePuller) Run(ctx context.Context) error { for { select { - case followChange, ok := <-ch: + case followChangeAggregate, ok := <-ch: if !ok { return nil // Channel closed, exit gracefully } - tokens, err := f.queries.GetTokens.Handle(ctx, followChange.Followee) + tokens, err := f.queries.GetTokens.Handle(ctx, followChangeAggregate.Followee) if err != nil { // Not one of our users, ignore continue } - f.logger.Debug().Message(followChange.String()) + f.logger.Debug().Message(followChangeAggregate.String()) for _, token := range tokens { - if err := f.apns.SendFollowChangeNotification(*followChange, token); err != nil { + if err := f.apns.SendFollowChangeNotification(*followChangeAggregate, token); err != nil { f.logger.Error(). WithField("token", token.Hex()). - WithField("followee", followChange.Followee.Hex()). + WithField("followee", followChangeAggregate.Followee.Hex()). WithError(err). Message("error sending follow change notification") continue diff --git a/service/domain/follow_change.go b/service/domain/follow_change.go deleted file mode 100644 index 5fc38b4..0000000 --- a/service/domain/follow_change.go +++ /dev/null @@ -1,69 +0,0 @@ -package domain - -import ( - "encoding/json" - "errors" - "fmt" - "time" -) - -type FollowChange struct { - ChangeType string `json:"changeType"` - At time.Time `json:"at"` - Follower PublicKey `json:"follower"` - Followee PublicKey `json:"followee"` - FriendlyFollowee string `json:"friendlyFollowee"` - FriendlyFollower string `json:"friendlyFollower"` -} - -func NewFollowChange(changeType string, follower PublicKey, friendlyFollower string, followee PublicKey, friendlyFollowee string, at time.Time) FollowChange { - return FollowChange{ - ChangeType: changeType, - Follower: follower, - FriendlyFollower: friendlyFollower, - Followee: followee, - FriendlyFollowee: friendlyFollowee, - At: at, - } -} - -func (f *FollowChange) UnmarshalJSON(data []byte) error { - var temp struct { - ChangeType string `json:"changeType"` - At int64 `json:"at"` - Follower string `json:"follower"` - Followee string `json:"followee"` - FriendlyFollowee string `json:"friendlyFollowee"` - FriendlyFollower string `json:"friendlyFollower"` - } - - if err := json.Unmarshal(data, &temp); err != nil { - return err - } - - f.ChangeType = temp.ChangeType - f.At = time.Unix(temp.At, 0) - f.FriendlyFollowee = temp.FriendlyFollowee - f.FriendlyFollower = temp.FriendlyFollower - - var err error - f.Follower, err = NewPublicKeyFromHex(temp.Follower) - if err != nil { - return errors.New("invalid hex for follower: " + err.Error()) - } - - f.Followee, err = NewPublicKeyFromHex(temp.Followee) - if err != nil { - return errors.New("invalid hex for followee: " + err.Error()) - } - - return nil -} - -func (f FollowChange) String() string { - if f.ChangeType == "unfollowed" { - return fmt.Sprintf("New unfollow: %s(%s) --x--> %s(%s)", f.FriendlyFollower, f.Follower.s, f.FriendlyFollowee, f.Followee.s) - } - - return fmt.Sprintf("New follow: %s(%s) -----> %s(%s)", f.FriendlyFollower, f.Follower.s, f.FriendlyFollowee, f.Followee.s) -} diff --git a/service/domain/follow_change_batch.go b/service/domain/follow_change_batch.go new file mode 100644 index 0000000..48e9f72 --- /dev/null +++ b/service/domain/follow_change_batch.go @@ -0,0 +1,75 @@ +package domain + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/nbd-wtf/go-nostr/nip19" +) + +// This is the struct coming from the follow-change pubsub topic produced by +// the followers service. The type represents a struct with the same name +// there. +type FollowChangeBatch struct { + Followee PublicKey `json:"followee"` + FriendlyFollower string `json:"friendlyFollower"` + Follows []PublicKey `json:"follows"` + Unfollows []PublicKey `json:"unfollows"` +} + +func (f *FollowChangeBatch) UnmarshalJSON(data []byte) error { + var temp struct { + Followee string `json:"followee"` + FriendlyFollower string `json:"friendlyFollower"` + Follows []string `json:"follows"` + Unfollows []string `json:"unfollows"` + } + + if err := json.Unmarshal(data, &temp); err != nil { + return err + } + + f.FriendlyFollower = temp.FriendlyFollower + + var err error + f.Followee, err = NewPublicKeyFromNpub(temp.Followee) + if err != nil { + return errors.New("invalid npub for followee: " + err.Error()) + } + + f.Follows = make([]PublicKey, len(temp.Follows)) + for i, npub := range temp.Follows { + f.Follows[i], err = NewPublicKeyFromNpub(npub) + if err != nil { + return errors.New("invalid npub for follow: " + err.Error()) + } + } + + f.Unfollows = make([]PublicKey, len(temp.Unfollows)) + for i, npub := range temp.Unfollows { + f.Unfollows[i], err = NewPublicKeyFromNpub(npub) + if err != nil { + return errors.New("invalid npub for unfollow: " + err.Error()) + } + } + + return nil +} + +func (f FollowChangeBatch) String() string { + friendlyFollowee, err := nip19.EncodePublicKey(f.Followee.Hex()) + if err != nil { + friendlyFollowee = f.Followee.Hex() + } + + if len(f.Follows)+len(f.Unfollows) == 1 { + if len(f.Follows) == 1 { + return fmt.Sprintf("Follow: %s -----> %s", f.FriendlyFollower, friendlyFollowee) + } else { + return fmt.Sprintf("Unfollow: %s --x--> %s", f.FriendlyFollower, friendlyFollowee) + } + } + + return fmt.Sprintf("Follow aggregate: %d follows, %d unfollows for %s", len(f.Follows), len(f.Unfollows), friendlyFollowee) +} diff --git a/service/domain/follow_change_batch_test.go b/service/domain/follow_change_batch_test.go new file mode 100644 index 0000000..92b59a6 --- /dev/null +++ b/service/domain/follow_change_batch_test.go @@ -0,0 +1,110 @@ +package domain_test + +import ( + "encoding/json" + "testing" + + "github.com/planetary-social/go-notification-service/internal/fixtures" + "github.com/planetary-social/go-notification-service/service/domain" + "github.com/stretchr/testify/assert" +) + +func TestFollowChangeBatch_UnmarshalJSON_Valid(t *testing.T) { + pk1, pk1Npub := fixtures.PublicKeyAndNpub() + pk2, pk2Npub := fixtures.PublicKeyAndNpub() + pk3, pk3Npub := fixtures.PublicKeyAndNpub() + pk4, pk4Npub := fixtures.PublicKeyAndNpub() + + jsonData := `{ + "followee": "` + pk1Npub + `", + "friendlyFollower": "FriendlyUser", + "follows": ["` + pk2Npub + `", "` + pk3Npub + `"], + "unfollows": ["` + pk4Npub + `"] + }` + + var batch domain.FollowChangeBatch + err := json.Unmarshal([]byte(jsonData), &batch) + + assert.NoError(t, err) + assert.Equal(t, pk1, batch.Followee) + assert.Equal(t, "FriendlyUser", batch.FriendlyFollower) + assert.Equal(t, []domain.PublicKey{pk2, pk3}, batch.Follows) + assert.Equal(t, []domain.PublicKey{pk4}, batch.Unfollows) +} + +func TestFollowChangeBatch_UnmarshalJSON_InvalidFollowee(t *testing.T) { + _, pk1Npub := fixtures.PublicKeyAndNpub() + + jsonData := `{ + "followee": "invalid", + "friendlyFollower": "FriendlyUser", + "follows": ["` + pk1Npub + `"], + "unfollows": [] + }` + + var batch domain.FollowChangeBatch + err := json.Unmarshal([]byte(jsonData), &batch) + assert.Error(t, err) + assert.EqualError(t, err, "invalid npub for followee: error decoding a nip19 entity: invalid bech32 string length 7") +} + +func TestFollowChangeBatch_UnmarshalJSON_InvalidFollows(t *testing.T) { + _, pk1Npub := fixtures.PublicKeyAndNpub() + + jsonData := `{ + "followee": "` + pk1Npub + `", + "friendlyFollower": "FriendlyUser", + "follows": ["invalid"], + "unfollows": [] + }` + + var batch domain.FollowChangeBatch + err := json.Unmarshal([]byte(jsonData), &batch) + assert.Error(t, err) + assert.EqualError(t, err, "invalid npub for follow: error decoding a nip19 entity: invalid bech32 string length 7") +} + +func TestFollowChangeBatch_String_SingleFollow(t *testing.T) { + pk1, pk1Npub := fixtures.PublicKeyAndNpub() + pk2, _ := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "FriendlyUser", + Follows: []domain.PublicKey{pk2}, + } + + expected := "Follow: FriendlyUser -----> " + pk1Npub + assert.Equal(t, expected, batch.String()) +} + +func TestFollowChangeBatch_String_SingleUnfollow(t *testing.T) { + pk1, pk1Npub := fixtures.PublicKeyAndNpub() + pk2, _ := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "FriendlyUser", + Unfollows: []domain.PublicKey{pk2}, + } + + expected := "Unfollow: FriendlyUser --x--> " + pk1Npub + assert.Equal(t, expected, batch.String()) +} + +func TestFollowChangeBatch_String_MultipleFollowsUnfollows(t *testing.T) { + pk1, pk1Npub := fixtures.PublicKeyAndNpub() + pk2, _ := fixtures.PublicKeyAndNpub() + pk3, _ := fixtures.PublicKeyAndNpub() + pk4, _ := fixtures.PublicKeyAndNpub() + + batch := domain.FollowChangeBatch{ + Followee: pk1, + FriendlyFollower: "FriendlyUser", + Follows: []domain.PublicKey{pk2, pk3}, + Unfollows: []domain.PublicKey{pk4}, + } + + expected := "Follow aggregate: 2 follows, 1 unfollows for " + pk1Npub + assert.Equal(t, expected, batch.String()) +}