diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 6c0915288..f81f9da5e 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -181,6 +181,39 @@ const docTemplate = `{ } } }, + "/api/v1/bookmarks/sync": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Get List of bookmark and last time of sync response bookmark change after that time and deleted bookmark.", + "parameters": [ + { + "description": "Bookmarks id in client side and last sync timestamp and page for pagination", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_v1.syncPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api_v1.syncResponseMessage" + } + }, + "403": { + "description": "Token not provided/invalid" + } + } + } + }, "/api/v1/system/info": { "get": { "description": "Get general system information like Shiori version, database, and OS", @@ -251,6 +284,23 @@ const docTemplate = `{ } }, "definitions": { + "api_v1.bookmarksModifiedResponse": { + "type": "object", + "properties": { + "bookmarks": { + "type": "array", + "items": { + "$ref": "#/definitions/model.BookmarkDTO" + } + }, + "maxPage": { + "type": "integer" + }, + "page": { + "type": "integer" + } + } + }, "api_v1.infoResponse": { "type": "object", "properties": { @@ -329,6 +379,40 @@ const docTemplate = `{ } } }, + "api_v1.syncPayload": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "last_sync": { + "type": "integer" + }, + "page": { + "type": "integer" + } + } + }, + "api_v1.syncResponseMessage": { + "type": "object", + "properties": { + "deleted": { + "type": "array", + "items": { + "type": "integer" + } + }, + "modified": { + "$ref": "#/definitions/api_v1.bookmarksModifiedResponse" + } + } + }, "api_v1.updateCachePayload": { "type": "object", "required": [ diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 2d2978e26..50ffcbcc4 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -170,6 +170,39 @@ } } }, + "/api/v1/bookmarks/sync": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Get List of bookmark and last time of sync response bookmark change after that time and deleted bookmark.", + "parameters": [ + { + "description": "Bookmarks id in client side and last sync timestamp and page for pagination", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_v1.syncPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api_v1.syncResponseMessage" + } + }, + "403": { + "description": "Token not provided/invalid" + } + } + } + }, "/api/v1/system/info": { "get": { "description": "Get general system information like Shiori version, database, and OS", @@ -240,6 +273,23 @@ } }, "definitions": { + "api_v1.bookmarksModifiedResponse": { + "type": "object", + "properties": { + "bookmarks": { + "type": "array", + "items": { + "$ref": "#/definitions/model.BookmarkDTO" + } + }, + "maxPage": { + "type": "integer" + }, + "page": { + "type": "integer" + } + } + }, "api_v1.infoResponse": { "type": "object", "properties": { @@ -318,6 +368,40 @@ } } }, + "api_v1.syncPayload": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "last_sync": { + "type": "integer" + }, + "page": { + "type": "integer" + } + } + }, + "api_v1.syncResponseMessage": { + "type": "object", + "properties": { + "deleted": { + "type": "array", + "items": { + "type": "integer" + } + }, + "modified": { + "$ref": "#/definitions/api_v1.bookmarksModifiedResponse" + } + } + }, "api_v1.updateCachePayload": { "type": "object", "required": [ diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 6917e65c5..f9c547c3e 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -1,4 +1,15 @@ definitions: + api_v1.bookmarksModifiedResponse: + properties: + bookmarks: + items: + $ref: '#/definitions/model.BookmarkDTO' + type: array + maxPage: + type: integer + page: + type: integer + type: object api_v1.infoResponse: properties: database: @@ -50,6 +61,28 @@ definitions: config: $ref: '#/definitions/model.UserConfig' type: object + api_v1.syncPayload: + properties: + ids: + items: + type: integer + type: array + last_sync: + type: integer + page: + type: integer + required: + - ids + type: object + api_v1.syncResponseMessage: + properties: + deleted: + items: + type: integer + type: array + modified: + $ref: '#/definitions/api_v1.bookmarksModifiedResponse' + type: object api_v1.updateCachePayload: properties: create_archive: @@ -257,6 +290,29 @@ paths: summary: Get readable version of bookmark. tags: - Auth + /api/v1/bookmarks/sync: + post: + parameters: + - description: Bookmarks id in client side and last sync timestamp and page + for pagination + in: body + name: payload + required: true + schema: + $ref: '#/definitions/api_v1.syncPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api_v1.syncResponseMessage' + "403": + description: Token not provided/invalid + summary: Get List of bookmark and last time of sync response bookmark change + after that time and deleted bookmark. + tags: + - Auth /api/v1/system/info: get: description: Get general system information like Shiori version, database, and diff --git a/internal/database/database.go b/internal/database/database.go index 211a91919..6cb63349b 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -29,7 +29,9 @@ type GetBookmarksOptions struct { IDs []int Tags []string ExcludedTags []string + IsDeleted []int Keyword string + LastSync string WithContent bool OrderMethod OrderMethod Limit int @@ -79,6 +81,9 @@ type DB interface { // SaveBookmarks saves bookmarks data to database. SaveBookmarks(ctx context.Context, create bool, bookmarks ...model.BookmarkDTO) ([]model.BookmarkDTO, error) + // GetDeletedBookmarks fetch list of bookmarks based on submitted options. + GetDeletedBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]int, error) + // GetBookmarks fetch list of bookmarks based on submitted options. GetBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]model.BookmarkDTO, error) diff --git a/internal/database/database_test.go b/internal/database/database_test.go index e05034739..249b4c296 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -39,6 +39,8 @@ func testDatabase(t *testing.T, dbFactory testDatabaseFactory) { "testSaveAccountSetting": testSaveAccountSettings, "testGetAccount": testGetAccount, "testGetAccounts": testGetAccounts, + // Sync + "testSync": testSync, } for testName, testCase := range tests { @@ -516,3 +518,45 @@ func testGetBoomarksWithTimeFilters(t *testing.T, db DB) { // Second id should be 2 if order them by id assert.Equal(t, booksOrderById[1].ID, 2) } + +func testSync(t *testing.T, db DB) { + ctx := context.TODO() + + // First Bookmark + book1 := model.BookmarkDTO{ + URL: "https://github.com/go-shiori/shiori/one", + Title: "first bookmark", + } + + _, err := db.SaveBookmarks(ctx, true, book1) + assert.NoError(t, err, "Save bookmarks must not fail") + + // Second bookmark + unixTimestampOneSecondLater := time.Now().UTC().Add(2 * time.Second).Unix() + book2 := model.BookmarkDTO{ + URL: "https://github.com/go-shiori/shiori/second", + Title: "second bookmark", + ModifiedAt: time.Unix(unixTimestampOneSecondLater, 0).UTC().Format(model.DatabaseDateFormat), + } + + _, err = db.SaveBookmarks(ctx, true, book2) + assert.NoError(t, err, "Save bookmarks must not fail") + + t.Run("get correct bookmarks based on LastSync", func(t *testing.T) { + booksAfterSpecificDate, err := db.GetBookmarks(ctx, GetBookmarksOptions{ + LastSync: book2.ModifiedAt, + }) + assert.NoError(t, err, "Get bookmarks must not fail") + assert.Equal(t, booksAfterSpecificDate[0].ID, 2) + assert.Len(t, booksAfterSpecificDate, 1) + }) + + t.Run("get deleted bookmarks id", func(t *testing.T) { + deletedBookarksIDs, err := db.GetDeletedBookmarks(ctx, GetBookmarksOptions{ + IsDeleted: []int{1, 5, 10}, + }) + assert.NoError(t, err, "Get deleted bookmarks must not fail") + assert.Equal(t, deletedBookarksIDs, []int{5, 10}) + assert.Len(t, deletedBookarksIDs, 2) + }) +} diff --git a/internal/database/mysql.go b/internal/database/mysql.go index 27db774d5..41d491eb5 100644 --- a/internal/database/mysql.go +++ b/internal/database/mysql.go @@ -282,6 +282,42 @@ func (db *MySQLDatabase) SaveBookmarks(ctx context.Context, create bool, bookmar return result, nil } +func (db *MySQLDatabase) GetDeletedBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]int, error) { + var missingIDs []int + + // Construct the query using UNION ALL to create a temporary table of IDs + var unionQueries []string + for _, id := range opts.IsDeleted { + unionQueries = append(unionQueries, fmt.Sprintf("SELECT %d AS id", id)) + } + unionQuery := strings.Join(unionQueries, " UNION ALL ") + + query := fmt.Sprintf("SELECT temp.id FROM (%s) AS temp LEFT JOIN bookmark ON temp.id = bookmark.id WHERE bookmark.id IS NULL", unionQuery) + + // Execute the query + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + // Scan the results into missingIDs + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + missingIDs = append(missingIDs, id) + } + + // Check for errors from iterating over rows + if err := rows.Err(); err != nil { + return nil, err + } + + return missingIDs, nil +} + // GetBookmarks fetch list of bookmarks based on submitted options. func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]model.BookmarkDTO, error) { // Create initial query @@ -312,6 +348,12 @@ func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOpti args = append(args, opts.IDs) } + // Add where clause for LastSync + if opts.LastSync != "" { + query += ` AND modified_at >= ?` + args = append(args, opts.LastSync) + } + // Add where clause for search keyword if opts.Keyword != "" { query += ` AND ( @@ -440,6 +482,12 @@ func (db *MySQLDatabase) GetBookmarksCount(ctx context.Context, opts GetBookmark args = append(args, opts.IDs) } + // Add where clause for LastSync + if opts.LastSync != "" { + query += ` AND modified_at >= ?` + args = append(args, opts.LastSync) + } + // Add where clause for search keyword if opts.Keyword != "" { query += ` AND ( diff --git a/internal/database/pg.go b/internal/database/pg.go index 66df2dd8d..1e2d25b7c 100644 --- a/internal/database/pg.go +++ b/internal/database/pg.go @@ -262,6 +262,43 @@ func (db *PGDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks return result, nil } +// GetDeletedBookmarks fetch list of bookmark that deleted from database. +func (db *PGDatabase) GetDeletedBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]int, error) { + var missingIDs []int + + // Construct the query using UNION ALL to create a temporary table of IDs + var unionQueries []string + for _, id := range opts.IsDeleted { + unionQueries = append(unionQueries, fmt.Sprintf("SELECT %d AS id", id)) + } + unionQuery := strings.Join(unionQueries, " UNION ALL ") + + query := fmt.Sprintf("SELECT temp.id FROM (%s) AS temp LEFT JOIN bookmark ON temp.id = bookmark.id WHERE bookmark.id IS NULL", unionQuery) + + // Execute the query + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + // Scan the results into missingIDs + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + missingIDs = append(missingIDs, id) + } + + // Check for errors from iterating over rows + if err := rows.Err(); err != nil { + return nil, err + } + + return missingIDs, nil +} + // GetBookmarks fetch list of bookmarks based on submitted options. func (db *PGDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]model.BookmarkDTO, error) { // Create initial query @@ -292,6 +329,12 @@ func (db *PGDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions arg["ids"] = opts.IDs } + // Add where clause for LastSync + if opts.LastSync != "" { + query += ` AND modified_at >= :last_sync` + arg["last_sync"] = opts.LastSync + } + // Add where clause for search keyword if opts.Keyword != "" { query += ` AND ( @@ -426,6 +469,12 @@ func (db *PGDatabase) GetBookmarksCount(ctx context.Context, opts GetBookmarksOp arg["ids"] = opts.IDs } + // Add where clause for LastSync + if opts.LastSync != "" { + query += ` AND modified_at >= :last_sync` + arg["last_sync"] = opts.LastSync + } + // Add where clause for search keyword if opts.Keyword != "" { query += ` AND ( diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index 0675db6aa..cc9c2b309 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -290,6 +290,43 @@ func (db *SQLiteDatabase) SaveBookmarks(ctx context.Context, create bool, bookma return result, nil } +// GetDeletedBookmarks fetch list of bookmark that deleted from database. +func (db *SQLiteDatabase) GetDeletedBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]int, error) { + var missingIDs []int + + // Construct the query using UNION ALL to create a temporary table of IDs + var unionQueries []string + for _, id := range opts.IsDeleted { + unionQueries = append(unionQueries, fmt.Sprintf("SELECT %d AS id", id)) + } + unionQuery := strings.Join(unionQueries, " UNION ALL ") + + query := fmt.Sprintf("SELECT temp.id FROM (%s) AS temp LEFT JOIN bookmark ON temp.id = bookmark.id WHERE bookmark.id IS NULL", unionQuery) + + // Execute the query + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + // Scan the results into missingIDs + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + missingIDs = append(missingIDs, id) + } + + // Check for errors from iterating over rows + if err := rows.Err(); err != nil { + return nil, err + } + + return missingIDs, nil +} + // GetBookmarks fetch list of bookmarks based on submitted options. func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]model.BookmarkDTO, error) { // Create initial query @@ -315,6 +352,12 @@ func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOpt args = append(args, opts.IDs) } + // Add where clause for LastSync + if opts.LastSync != "" { + query += ` AND b.modified_at >= ?` + args = append(args, opts.LastSync) + } + // Add where clause for search keyword if opts.Keyword != "" { query += ` AND (b.url LIKE '%' || ? || '%' OR b.excerpt LIKE '%' || ? || '%' OR b.id IN ( @@ -416,7 +459,7 @@ func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOpt } // store bookmark IDs for further enrichment - var bookmarkIds = make([]int, 0, len(bookmarks)) + bookmarkIds := make([]int, 0, len(bookmarks)) for _, book := range bookmarks { bookmarkIds = append(bookmarkIds, book.ID) } @@ -509,6 +552,12 @@ func (db *SQLiteDatabase) GetBookmarksCount(ctx context.Context, opts GetBookmar args = append(args, opts.IDs) } + // Add where clause for LastSync + if opts.LastSync != "" { + query += ` AND b.modified_at >= ?` + args = append(args, opts.LastSync) + } + // Add where clause for search keyword if opts.Keyword != "" { query += ` AND (b.url LIKE '%' || ? || '%' OR b.excerpt LIKE '%' || ? || '%' OR b.id IN ( diff --git a/internal/http/routes/api/v1/bookmarks.go b/internal/http/routes/api/v1/bookmarks.go index a95ec538b..73fc09488 100644 --- a/internal/http/routes/api/v1/bookmarks.go +++ b/internal/http/routes/api/v1/bookmarks.go @@ -2,11 +2,13 @@ package api_v1 import ( "fmt" + "math" "net/http" "os" fp "path/filepath" "strconv" "sync" + "time" "github.com/gin-gonic/gin" "github.com/go-shiori/shiori/internal/core" @@ -28,6 +30,8 @@ func (r *BookmarksAPIRoutes) Setup(g *gin.RouterGroup) model.Routes { g.Use(middleware.AuthenticationRequired()) g.PUT("/cache", r.updateCache) g.GET("/:id/readable", r.bookmarkReadable) + g.POST("/sync", r.sync) + return r } @@ -114,6 +118,110 @@ func (r *BookmarksAPIRoutes) bookmarkReadable(c *gin.Context) { }) } +type syncPayload struct { + Ids []int `json:"ids" validate:"required"` + LastSync int64 `json:"last_sync"` + Page int `json:"page"` +} + +func (p *syncPayload) IsValid() error { + for _, id := range p.Ids { + if id <= 0 { + return fmt.Errorf("id should not be 0 or negative") + } + } + return nil +} + +type bookmarksModifiedResponse struct { + Bookmarks []model.BookmarkDTO `json:"bookmarks"` + Page int `json:"page"` + MaxPage int `json:"maxPage"` +} + +type syncResponseMessage struct { + Deleted []int `json:"deleted"` + Modified bookmarksModifiedResponse `json:"modified"` +} + +// Bookmark Sync godoc +// +// @Summary Get List of bookmark and last time of sync response bookmark change after that time and deleted bookmark. +// @Tags Auth +// @securityDefinitions.apikey ApiKeyAuth +// @Param payload body syncPayload true "Bookmarks id in client side and last sync timestamp and page for pagination"` +// @Produce json +// @Success 200 {object} syncResponseMessage +// @Failure 403 {object} nil "Token not provided/invalid" +// @Router /api/v1/bookmarks/sync [post] +func (r *BookmarksAPIRoutes) sync(c *gin.Context) { + var payload syncPayload + if err := c.ShouldBindJSON(&payload); err != nil { + response.SendInternalServerError(c) + return + } + + if err := payload.IsValid(); err != nil { + response.SendError(c, http.StatusBadRequest, err.Error()) + return + } + + lastsyncformat := time.Unix(payload.LastSync, 0).UTC().Format(model.DatabaseDateFormat) + + page := payload.Page + if payload.Page < 1 { + page = 1 + } + + filter := database.GetBookmarksOptions{ + LastSync: lastsyncformat, + IsDeleted: payload.Ids, + Limit: 30, + Offset: (page - 1) * 30, + } + + // Calculate max page + nBookmarks, err := r.deps.Database.GetBookmarksCount(c, filter) + if err != nil { + r.logger.WithError(err).Error("error getting bookmakrs number") + response.SendInternalServerError(c) + return + } + + maxPage := int(math.Ceil(float64(nBookmarks) / 30)) + + bookmarks, err := r.deps.Database.GetBookmarks(c, filter) + if err != nil { + r.logger.WithError(err).Error("error getting bookmakrs") + response.SendInternalServerError(c) + return + } + + // Get Deleted Bookmarks + var deletedBookmarks []int + + if len(payload.Ids) > 0 && page == 1 { + deletedBookmarks, err = r.deps.Database.GetDeletedBookmarks(c, filter) + if err != nil { + r.logger.WithError(err).Error("error getting bookmakrs") + response.SendInternalServerError(c) + return + } + } + + // Create response using syncResponseMessage struct + resp := syncResponseMessage{ + Deleted: deletedBookmarks, + Modified: bookmarksModifiedResponse{ + Bookmarks: bookmarks, + Page: page, + MaxPage: maxPage, + }, + } + + response.Send(c, 200, resp) +} + // updateCache godoc // // @Summary Update Cache and Ebook on server. diff --git a/internal/http/routes/api/v1/bookmarks_test.go b/internal/http/routes/api/v1/bookmarks_test.go index 7af902565..bc793b4a5 100644 --- a/internal/http/routes/api/v1/bookmarks_test.go +++ b/internal/http/routes/api/v1/bookmarks_test.go @@ -2,6 +2,7 @@ package api_v1 import ( "context" + "encoding/json" "net/http" "testing" "time" @@ -93,5 +94,161 @@ func TestReadableeBookmarkContent(t *testing.T) { require.Equal(t, response, w.Body.String()) require.Equal(t, http.StatusOK, w.Code) }) +} + +func TestSync(t *testing.T) { + logger := logrus.New() + ctx := context.TODO() + + g := gin.New() + + _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) + g.Use(middleware.AuthMiddleware(deps)) + + router := NewBookmarksAPIRoutes(logger, deps) + router.Setup(g.Group("/")) + + account := model.Account{ + Username: "test", + Password: "test", + Owner: false, + } + require.NoError(t, deps.Database.SaveAccount(ctx, account)) + token, err := deps.Domains.Auth.CreateTokenForAccount(&account, time.Now().Add(time.Minute)) + require.NoError(t, err) + + // all payloads need + payloadInvalidID := syncPayload{ + Ids: []int{0, -1}, + LastSync: 0, + Page: 1, + } + // Json format of payloads + payloadJSONInvalidID, err := json.Marshal(payloadInvalidID) + if err != nil { + logrus.Printf("can't create a valid json") + } + + // Add bookmarks to the database + bookmarkFirst := testutil.GetValidBookmark() + _, err = deps.Database.SaveBookmarks(ctx, true, *bookmarkFirst) + require.NoError(t, err) + + bookmarkSecond := testutil.GetValidBookmark() + bookmarkSecond.Title = "second bookmark" + unixTimestampOneSecondLater := time.Now().UTC().Add(1 * time.Second).Unix() + bookmarkSecond.ModifiedAt = time.Unix(unixTimestampOneSecondLater, 0).UTC().Format(model.DatabaseDateFormat) + _, err = deps.Database.SaveBookmarks(ctx, true, *bookmarkSecond) + require.NoError(t, err) + + payloadValid := syncPayload{ + Ids: []int{}, + LastSync: unixTimestampOneSecondLater, + Page: 1, + } + + payloadValidWithIDs := syncPayload{ + Ids: []int{3, 2, 7}, + LastSync: unixTimestampOneSecondLater, + Page: 1, + } + + payloadJSONValid, err := json.Marshal(payloadValid) + if err != nil { + logrus.Printf("can't create a valid json") + } + payloadJSONValidWithIDs, err := json.Marshal(payloadValidWithIDs) + if err != nil { + logrus.Printf("can't create a valid json") + } + + t.Run("require authentication", func(t *testing.T) { + w := testutil.PerformRequest(g, "POST", "/sync") + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("invalid id", func(t *testing.T) { + w := testutil.PerformRequest(g, "POST", "/sync", testutil.WithHeader(model.AuthorizationHeader, model.AuthorizationTokenType+" "+token), testutil.WithBody(string(payloadJSONInvalidID))) + require.Equal(t, http.StatusBadRequest, w.Code) + + // Check the response body + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err, "failed to unmarshal response body") + + // Assert that the response message is as expected for 0 or negative id + require.Equal(t, "id should not be 0 or negative", response["message"]) + }) + + t.Run("retun just second bookmark with LastSync option sync api", func(t *testing.T) { + w := testutil.PerformRequest(g, "POST", "/sync", testutil.WithHeader(model.AuthorizationHeader, model.AuthorizationTokenType+" "+token), testutil.WithBody(string(payloadJSONValid))) + require.Equal(t, http.StatusOK, w.Code) + + // Check the response body + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err, "failed to unmarshal response body") + + // Assert that the response message is as expected + require.Equal(t, true, response["ok"]) + + // Access the bookmarks + message := response["message"].(map[string]interface{}) + deleted := message["deleted"] + modified := message["modified"].(map[string]interface{}) + bookmarks := modified["bookmarks"].([]interface{}) + + // Check the IDs of the bookmarks + var ids []int + for _, bookmark := range bookmarks { + bookmarkMap := bookmark.(map[string]interface{}) + id := int(bookmarkMap["id"].(float64)) + ids = append(ids, id) + } + + // Assert that the IDs are as expected + expectedIDs := []int{2} + require.ElementsMatch(t, expectedIDs, ids, "bookmark IDs do not match") + require.Nil(t, deleted, "deleted bookmark is not nil") + }) + + t.Run("retun deleted bookmark", func(t *testing.T) { + w := testutil.PerformRequest(g, "POST", "/sync", testutil.WithHeader(model.AuthorizationHeader, model.AuthorizationTokenType+" "+token), testutil.WithBody(string(payloadJSONValidWithIDs))) + require.Equal(t, http.StatusOK, w.Code) + + // Check the response body + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err, "failed to unmarshal response body") + + // Assert that the response message is as expected + require.Equal(t, true, response["ok"]) + + // Access the bookmarks + message := response["message"].(map[string]interface{}) + deleted := message["deleted"].([]interface{}) + modified := message["modified"].(map[string]interface{}) + bookmarks := modified["bookmarks"].([]interface{}) + + // Check the IDs of the bookmarks + var ids []int + for _, bookmark := range bookmarks { + bookmarkMap := bookmark.(map[string]interface{}) + id := int(bookmarkMap["id"].(float64)) + ids = append(ids, id) + } + // Convert deleted IDs to int + var deletedIDs []int + for _, del := range deleted { + deletedID := int(del.(float64)) // Convert each deleted ID to int + deletedIDs = append(deletedIDs, deletedID) + } + + // Assert that the IDs are as expected + expectedIDs := []int{2} + expectedDeletedIDs := []int{3, 7} + require.ElementsMatch(t, expectedIDs, ids, "bookmark IDs do not match") + require.ElementsMatch(t, expectedDeletedIDs, deletedIDs, "deleted bookmark IDs do not match") + }) }