Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3697,12 +3697,12 @@ const docTemplate = `{
"created_at",
"id",
"is_archived",
"is_read",
"last_message_content",
"last_message_id",
"order_timestamp",
"owner",
"status",
"unread_count",
"updated_at",
"user_id"
],
Expand All @@ -3727,10 +3727,6 @@ const docTemplate = `{
"type": "boolean",
"example": false
},
"is_read": {
"type": "boolean",
"example": true
},
"last_message_content": {
"type": "string",
"example": "This is a sample message content"
Expand All @@ -3751,6 +3747,10 @@ const docTemplate = `{
"type": "string",
"example": "PENDING"
},
"unread_count": {
"type": "integer",
"example": 2
},
"updated_at": {
"type": "string",
"example": "2022-06-05T14:26:09.527976+03:00"
Expand Down Expand Up @@ -4404,9 +4404,11 @@ const docTemplate = `{
"type": "boolean",
"example": true
},
"is_read": {
"type": "boolean",
"example": true
"unread_count": {
"type": "integer",
"maximum": 0,
"minimum": 0,
"example": 0
}
}
},
Expand Down
18 changes: 10 additions & 8 deletions api/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -3694,12 +3694,12 @@
"created_at",
"id",
"is_archived",
"is_read",
"last_message_content",
"last_message_id",
"order_timestamp",
"owner",
"status",
"unread_count",
"updated_at",
"user_id"
],
Expand All @@ -3724,10 +3724,6 @@
"type": "boolean",
"example": false
},
"is_read": {
"type": "boolean",
"example": true
},
"last_message_content": {
"type": "string",
"example": "This is a sample message content"
Expand All @@ -3748,6 +3744,10 @@
"type": "string",
"example": "PENDING"
},
"unread_count": {
"type": "integer",
"example": 2
},
"updated_at": {
"type": "string",
"example": "2022-06-05T14:26:09.527976+03:00"
Expand Down Expand Up @@ -4401,9 +4401,11 @@
"type": "boolean",
"example": true
},
"is_read": {
"type": "boolean",
"example": true
"unread_count": {
"type": "integer",
"maximum": 0,
"minimum": 0,
"example": 0
}
}
},
Expand Down
16 changes: 9 additions & 7 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,6 @@ definitions:
is_archived:
example: false
type: boolean
is_read:
example: true
type: boolean
last_message_content:
example: This is a sample message content
type: string
Expand All @@ -337,6 +334,9 @@ definitions:
status:
example: PENDING
type: string
unread_count:
example: 2
type: integer
updated_at:
example: "2022-06-05T14:26:09.527976+03:00"
type: string
Expand All @@ -349,12 +349,12 @@ definitions:
- created_at
- id
- is_archived
- is_read
- last_message_content
- last_message_id
- order_timestamp
- owner
- status
- unread_count
- updated_at
- user_id
type: object
Expand Down Expand Up @@ -857,9 +857,11 @@ definitions:
is_archived:
example: true
type: boolean
is_read:
example: true
type: boolean
unread_count:
example: 0
maximum: 0
minimum: 0
type: integer
type: object
requests.PhoneAPIKeyStoreRequest:
properties:
Expand Down
5 changes: 3 additions & 2 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import (

"github.com/NdoleStudio/httpsms/pkg/entities"
"github.com/NdoleStudio/httpsms/pkg/listeners"
"github.com/NdoleStudio/httpsms/pkg/migrations"
"github.com/NdoleStudio/httpsms/pkg/repositories"
"github.com/NdoleStudio/httpsms/pkg/services"
"github.com/NdoleStudio/stacktrace"
Expand Down Expand Up @@ -374,8 +375,8 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.Message{}))
}

if err = db.AutoMigrate(&entities.MessageThread{}); err != nil {
container.logger.Fatal(stacktrace.Propagatef(err, "cannot migrate %T", &entities.MessageThread{}))
if err = migrations.MigrateMessageThreadUnreadCount(db); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate message thread unread counts"))
}

if err = db.AutoMigrate(&entities.User{}); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion api/pkg/entities/message_thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type MessageThread struct {
Owner string `json:"owner" example:"+18005550199"`
Contact string `json:"contact" example:"+18005550100"`
IsArchived bool `json:"is_archived" example:"false"`
IsRead bool `json:"is_read" gorm:"not null;default:true" example:"true"`
UnreadCount uint `json:"unread_count" gorm:"not null;default:0" example:"2"`
LastReadAt time.Time `json:"-" gorm:"not null;default:CURRENT_TIMESTAMP"`
UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
Color string `json:"color" example:"indigo"`
Expand Down
8 changes: 8 additions & 0 deletions api/pkg/entities/message_thread_deleted_item.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package entities

import "github.com/google/uuid"

// MessageThreadDeletedItem records a permanently deleted message activity.
type MessageThreadDeletedItem struct {
MessageID uuid.UUID `gorm:"primaryKey;type:uuid"`
}
44 changes: 37 additions & 7 deletions api/pkg/entities/message_thread_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,48 @@ import (
"github.com/stretchr/testify/require"
)

func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) {
func TestMessageThreadUnreadFields(t *testing.T) {
threadType := reflect.TypeOf(MessageThread{})

isRead, ok := threadType.FieldByName("IsRead")
_, hasIsRead := threadType.FieldByName("IsRead")
assert.False(t, hasIsRead)

unreadCount, ok := threadType.FieldByName("UnreadCount")
require.True(t, ok)
assert.Contains(t, isRead.Tag.Get("gorm"), "not null")
assert.Contains(t, isRead.Tag.Get("gorm"), "default:true")
assert.Equal(t, "is_read", isRead.Tag.Get("json"))
assert.Equal(t, "unread_count", unreadCount.Tag.Get("json"))
assert.Contains(t, unreadCount.Tag.Get("gorm"), "not null")
assert.Contains(t, unreadCount.Tag.Get("gorm"), "default:0")

lastReadAt, ok := threadType.FieldByName("LastReadAt")
require.True(t, ok)
assert.Contains(t, lastReadAt.Tag.Get("gorm"), "not null")
assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP")
assert.Equal(t, "-", lastReadAt.Tag.Get("json"))
}

func TestMessageThreadUnreadItemUsesMessageIDAsPrimaryKey(t *testing.T) {
itemType := reflect.TypeOf(MessageThreadUnreadItem{})

messageID, ok := itemType.FieldByName("MessageID")
require.True(t, ok)
assert.Contains(t, messageID.Tag.Get("gorm"), "primaryKey")
}

func TestMessageThreadUnreadItemRetainsCountedState(t *testing.T) {
itemType := reflect.TypeOf(MessageThreadUnreadItem{})

counted, ok := itemType.FieldByName("Counted")
require.True(t, ok)
assert.Contains(t, counted.Tag.Get("gorm"), "not null")
assert.Contains(t, counted.Tag.Get("gorm"), "default:true")
}

func TestMessageThreadDeletedItemIsIndependentFromThreadLifecycle(t *testing.T) {
itemType := reflect.TypeOf(MessageThreadDeletedItem{})

require.Equal(t, 1, itemType.NumField())
messageID, ok := itemType.FieldByName("MessageID")
require.True(t, ok)
assert.Contains(t, messageID.Tag.Get("gorm"), "primaryKey")
assert.Contains(t, messageID.Tag.Get("gorm"), "type:uuid")
_, hasMessageThreadID := itemType.FieldByName("MessageThreadID")
assert.False(t, hasMessageThreadID)
}
11 changes: 11 additions & 0 deletions api/pkg/entities/message_thread_unread_item.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package entities

import "github.com/google/uuid"

// MessageThreadUnreadItem records an inbound item currently counted as unread.
type MessageThreadUnreadItem struct {
MessageID uuid.UUID `gorm:"primaryKey;type:uuid"`
MessageThreadID uuid.UUID `gorm:"not null;type:uuid;index"`
Counted bool `gorm:"not null;default:true"`
MessageThread MessageThread `gorm:"constraint:OnDelete:CASCADE;"`
}
34 changes: 32 additions & 2 deletions api/pkg/handlers/message_thread_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import (

type messageThreadHandlerRepositoryStub struct{}

func (stub *messageThreadHandlerRepositoryStub) Store(context.Context, *entities.MessageThread) error {
func (stub *messageThreadHandlerRepositoryStub) Store(context.Context, repositories.MessageThreadStoreParams) error {
return nil
}

Expand Down Expand Up @@ -75,7 +75,7 @@ func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testin
handler.RegisterRoutes(app)

messageThreadID := uuid.New()
req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"is_read":true}`))
req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"unread_count":0}`))
req.Header.Set("Content-Type", "application/json")

resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})
Expand All @@ -90,6 +90,36 @@ func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testin
require.Equal(t, "cannot find message thread with ID ["+messageThreadID.String()+"]", payload.Message)
}

func TestMessageThreadHandlerUpdate_RejectsLegacyIsReadPayload(t *testing.T) {
logger := &messageThreadHandlerNoopLogger{}
tracer := telemetry.NewOtelLogger("test", logger)
service := services.NewMessageThreadService(logger, tracer, &messageThreadHandlerRepositoryStub{}, nil, nil)
handler := NewMessageThreadHandler(logger, tracer, validators.NewMessageThreadHandlerValidator(logger, tracer), service)

app := fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"})
return c.Next()
})
handler.RegisterRoutes(app)

req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+uuid.NewString(), bytes.NewBufferString(`{"is_read":true}`))
req.Header.Set("Content-Type", "application/json")

resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second})

require.NoError(t, err)
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)

var payload struct {
Message string `json:"message"`
Data map[string][]string `json:"data"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Equal(t, "validation errors while updating message thread", payload.Message)
require.Equal(t, []string{"at least one of is_archived or unread_count is required"}, payload.Data["payload"])
}

type messageThreadHandlerNoopLogger struct{}

var _ telemetry.Logger = (*messageThreadHandlerNoopLogger)(nil)
Expand Down
4 changes: 2 additions & 2 deletions api/pkg/listeners/message_thread_listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ func (listener *MessageThreadListener) OnMessagePhoneReceived(ctx context.Contex
Status: entities.MessageStatusReceived,
Content: payload.Content,
MessageID: payload.MessageID,
MarkAsUnread: true,
CountAsUnread: true,
EventTimestamp: event.Time(),
}

Expand Down Expand Up @@ -246,7 +246,7 @@ func (listener *MessageThreadListener) OnMessageCallMissed(ctx context.Context,
Timestamp: payload.Timestamp,
Content: "Missed phone call",
MessageID: payload.MessageID,
MarkAsUnread: true,
CountAsUnread: true,
EventTimestamp: event.Time(),
}
if err := listener.service.UpdateThread(ctx, params); err != nil {
Expand Down
34 changes: 32 additions & 2 deletions api/pkg/listeners/message_thread_listener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestMessageThreadListenerMarksInboundMessageUnread(t *testing.T) {
err := routes[events.EventTypeMessagePhoneReceived](context.Background(), event)

require.NoError(t, err)
assert.True(t, repository.activity.MarkAsUnread)
assert.True(t, repository.activity.CountAsUnread)
assert.Equal(t, event.Time(), repository.activity.EventTimestamp)
}

Expand All @@ -57,11 +57,41 @@ func TestMessageThreadListenerMarksMissedCallUnread(t *testing.T) {
err := routes[events.MessageCallMissed](context.Background(), event)

require.NoError(t, err)
assert.True(t, repository.activity.MarkAsUnread)
assert.True(t, repository.activity.CountAsUnread)
assert.Equal(t, "Missed phone call", repository.activity.Content)
assert.Equal(t, event.Time(), repository.activity.EventTimestamp)
}

func TestMessageThreadListenerDeletesNonLastUnreadMessage(t *testing.T) {
repository, routes := newMessageThreadListenerForTest()
deletedMessageID := uuid.New()
previousMessageID := uuid.New()
previousStatus := entities.MessageStatus(entities.MessageStatusDelivered)
previousContent := "previous"
event := cloudevents.NewEvent()
event.SetID(uuid.NewString())
event.SetSource("/v1/messages/deleted")
event.SetType(events.MessageAPIDeleted)
require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.MessageAPIDeletedPayload{
MessageID: deletedMessageID,
UserID: entities.UserID("user-id"),
Owner: "+18005550199",
Contact: "+18005550100",
PreviousMessageID: &previousMessageID,
PreviousMessageStatus: &previousStatus,
PreviousMessageContent: &previousContent,
}))

err := routes[events.MessageAPIDeleted](context.Background(), event)

require.NoError(t, err)
assert.Equal(t, deletedMessageID, repository.deletedUpdate.DeletedMessageID)
assert.Equal(t, "+18005550199", repository.deletedUpdate.Owner)
assert.Equal(t, "+18005550100", repository.deletedUpdate.Contact)
require.NotNil(t, repository.deletedUpdate.LastMessageID)
assert.Equal(t, previousMessageID, *repository.deletedUpdate.LastMessageID)
}

func newMessageThreadListenerForTest() (*listenerMessageThreadRepository, map[string]events.EventListener) {
repository := &listenerMessageThreadRepository{}
logger := &noopListenerLogger{}
Expand Down
Loading
Loading