From 129b1200e63de8b88a66b1af4d576817f56e013b Mon Sep 17 00:00:00 2001 From: qwqcode Date: Sun, 6 Oct 2024 22:26:41 +0800 Subject: [PATCH] feat(api/vote): add get vote status endpoint A new HTTP API endpoint `GET /votes/:target_name/:target_id` is now available. Response example: ``` {"up":1,"down":1,"is_up":true,"is_down":false} ``` - Implement GET endpoint for retrieving vote status - Add tests for vote functionality Related to: PR#997 This is a prerequisite PR for #983. --- docs/swagger/docs.go | 98 +++++++++++++++++++ docs/swagger/swagger.json | 98 +++++++++++++++++++ docs/swagger/swagger.yaml | 57 +++++++++++ server/handler/base_test.go | 4 +- server/handler/vote.go | 31 ++++++ server/handler/vote_test.go | 185 ++++++++++++++++++++++++++++++++++++ server/server.go | 1 + test/fixtures/atk_votes.yml | 51 ++++++++++ ui/artalk/src/api/v2.ts | 35 +++++++ 9 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 server/handler/vote_test.go create mode 100644 test/fixtures/atk_votes.yml diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 487e6816..c0634c58 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -3525,6 +3525,104 @@ const docTemplate = `{ } } }, + "/votes/{target_name}/{target_id}": { + "get": { + "description": "Get vote status for a specific comment or page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vote" + ], + "summary": "Get Vote Status", + "operationId": "GetVote", + "parameters": [ + { + "enum": [ + "comment", + "page" + ], + "type": "string", + "description": "The name of vote target", + "name": "target_name", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The target comment or page ID", + "name": "target_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseVote" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/votes/{target_name}/{target_id}/{choice}": { "post": { "description": "Create a new vote for a specific comment or page", diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 16c7a16f..aabb12a7 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -3518,6 +3518,104 @@ } } }, + "/votes/{target_name}/{target_id}": { + "get": { + "description": "Get vote status for a specific comment or page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Vote" + ], + "summary": "Get Vote Status", + "operationId": "GetVote", + "parameters": [ + { + "enum": [ + "comment", + "page" + ], + "type": "string", + "description": "The name of vote target", + "name": "target_name", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The target comment or page ID", + "name": "target_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseVote" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/votes/{target_name}/{target_id}/{choice}": { "post": { "description": "Create a new vote for a specific comment or page", diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 5ed14bfe..a2e89d47 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -3284,6 +3284,63 @@ paths: summary: Get Version Info tags: - System + /votes/{target_name}/{target_id}: + get: + consumes: + - application/json + description: Get vote status for a specific comment or page + operationId: GetVote + parameters: + - description: The name of vote target + enum: + - comment + - page + in: path + name: target_name + required: true + type: string + - description: The target comment or page ID + in: path + name: target_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.ResponseVote' + "403": + description: Forbidden + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "404": + description: Not Found + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "500": + description: Internal Server Error + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + summary: Get Vote Status + tags: + - Vote /votes/{target_name}/{target_id}/{choice}: post: consumes: diff --git a/server/handler/base_test.go b/server/handler/base_test.go index 3a178f61..a7807363 100644 --- a/server/handler/base_test.go +++ b/server/handler/base_test.go @@ -7,6 +7,8 @@ import ( func NewApiTestApp() (*test.TestApp, *fiber.App) { app, _ := test.NewTestApp() - fiberApp := fiber.New() + fiberApp := fiber.New(fiber.Config{ + ProxyHeader: "X-Forwarded-For", + }) return app, fiberApp } diff --git a/server/handler/vote.go b/server/handler/vote.go index db959ced..e783f957 100644 --- a/server/handler/vote.go +++ b/server/handler/vote.go @@ -18,6 +18,37 @@ type ResponseVote struct { IsDown bool `json:"is_down"` } +// @Id GetVote +// @Summary Get Vote Status +// @Description Get vote status for a specific comment or page +// @Tags Vote +// @Param target_name path string true "The name of vote target" Enums(comment, page) +// @Param target_id path int true "The target comment or page ID" +// @Accept json +// @Produce json +// @Success 200 {object} ResponseVote +// @Failure 403 {object} Map{msg=string} +// @Failure 404 {object} Map{msg=string} +// @Failure 500 {object} Map{msg=string} +// @Router /votes/{target_name}/{target_id} [get] +func VoteGet(app *core.App, router fiber.Router) { + router.Get("/votes/:target_name/:target_id", func(c *fiber.Ctx) error { + targetName := c.Params("target_name") + targetID, _ := c.ParamsInt("target_id") + + var result ResponseVote + result.Up, result.Down = app.Dao().GetVoteNumUpDown(targetName, uint(targetID)) + exitsVotes := getExistsVotesByIP(app.Dao(), c.IP(), targetName, uint(targetID)) + if len(exitsVotes) > 0 { + choice := getVoteChoice(string(exitsVotes[0].Type)) + result.IsUp = choice == "up" + result.IsDown = choice == "down" + } + + return common.RespData(c, result) + }) +} + type ParamsVoteCreate struct { Name string `json:"name" validate:"optional"` // The username Email string `json:"email" validate:"optional"` // The user email diff --git a/server/handler/vote_test.go b/server/handler/vote_test.go new file mode 100644 index 00000000..bfbb13fc --- /dev/null +++ b/server/handler/vote_test.go @@ -0,0 +1,185 @@ +package handler_test + +import ( + "bytes" + "io" + "net/http/httptest" + "testing" + + "github.com/artalkjs/artalk/v2/server/handler" + "github.com/stretchr/testify/assert" +) + +func TestVote(t *testing.T) { + tests := []struct { + description string + method string + url string + body string + ip string + expectedCode int + expectedBody func(t *testing.T, body string) + }{ + { + description: "Get comment vote status (original up)", + method: "GET", + url: "/votes/comment/1000", + expectedCode: 200, + ip: "127.0.0.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":4,"down":2,"is_up":true,"is_down":false}`, body) + }, + }, + { + description: "Get page vote status (original down)", + method: "GET", + url: "/votes/page/1001", + expectedCode: 200, + ip: "127.0.0.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":1,"down":2,"is_up":false,"is_down":true}`, body) + }, + }, + { + description: "Get comment vote status (original null)", + method: "GET", + url: "/votes/comment/1001", + expectedCode: 200, + ip: "127.0.0.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":0,"down":0,"is_up":false,"is_down":false}`, body) + }, + }, + { + description: "Get page vote status (original null)", + method: "GET", + url: "/votes/page/1002", + expectedCode: 200, + ip: "127.0.0.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":0,"down":0,"is_up":false,"is_down":false}`, body) + }, + }, + { + description: "Create comment up vote (original null, set to up)", + method: "POST", + url: "/votes/comment/1000/up", + body: "{}", + expectedCode: 200, + ip: "192.168.1.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":5,"down":2,"is_up":true,"is_down":false}`, body) + }, + }, + { + description: "Create comment down vote (original null, set to down)", + method: "POST", + url: "/votes/comment/1000/down", + body: "{}", + expectedCode: 200, + ip: "192.168.1.2", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":4,"down":3,"is_up":false,"is_down":true}`, body) + }, + }, + { + description: "Create page up vote (original null, set to up)", + method: "POST", + url: "/votes/page/1001/up", + body: "{}", + expectedCode: 200, + ip: "192.168.1.3", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":2,"down":2,"is_up":true,"is_down":false}`, body) + }, + }, + { + description: "Create page down vote (original null, set to down)", + method: "POST", + url: "/votes/page/1001/down", + body: "{}", + expectedCode: 200, + ip: "192.168.1.4", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":1,"down":3,"is_up":false,"is_down":true}`, body) + }, + }, + { + description: "Un-vote comment comment (original up, revoke up)", + method: "POST", + url: "/votes/comment/1000/up", + body: "{}", + expectedCode: 200, + ip: "127.0.0.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":3,"down":2,"is_up":false,"is_down":false}`, body) + }, + }, + { + description: "Un-vote page comment (original down, revoke down)", + method: "POST", + url: "/votes/page/1001/down", + body: "{}", + expectedCode: 200, + ip: "127.0.0.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":1,"down":1,"is_up":false,"is_down":false}`, body) + }, + }, + { + description: "Opposite-vote comment comment (original up, set to down)", + method: "POST", + url: "/votes/comment/1000/down", + body: "{}", + expectedCode: 200, + ip: "127.0.0.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":3,"down":3,"is_up":false,"is_down":true}`, body) + }, + }, + { + description: "Opposite-vote page comment (original down, set to up)", + method: "POST", + url: "/votes/page/1001/up", + body: "{}", + expectedCode: 200, + ip: "127.0.0.1", + expectedBody: func(t *testing.T, body string) { + assert.NotEmpty(t, body) + assert.Equal(t, `{"up":2,"down":1,"is_up":true,"is_down":false}`, body) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + app, fiber := NewApiTestApp() + defer app.Cleanup() + + handler.VoteGet(app.App, fiber) + handler.VoteCreate(app.App, fiber) + + req := httptest.NewRequest(tt.method, tt.url, bytes.NewReader([]byte(tt.body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", tt.ip) // mock IP + resp, err := fiber.Test(req) + assert.NoError(t, err) + assert.Equal(t, tt.expectedCode, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + tt.expectedBody(t, string(body)) + }) + } +} diff --git a/server/server.go b/server/server.go index 7100d6e8..a4af4191 100644 --- a/server/server.go +++ b/server/server.go @@ -71,6 +71,7 @@ func Serve(app *core.App) (*fiber.App, error) { h.CommentCreate(app, api) h.CommentList(app, api) h.CommentGet(app, api) + h.VoteGet(app, api) h.VoteCreate(app, api) h.PagePV(app, api) h.Stat(app, api) diff --git a/test/fixtures/atk_votes.yml b/test/fixtures/atk_votes.yml new file mode 100644 index 00000000..312fd1aa --- /dev/null +++ b/test/fixtures/atk_votes.yml @@ -0,0 +1,51 @@ +# Comment 1000: 4 up, 2 down +# Page 1001: 1 up, 2 down +# The IP 127.0.0.1 had up-voted Comment 1000 and down-voted Page 1001. + +- id: 1000 + target_id: 1000 + type: comment_up + user_id: 1000 + ip: 127.0.0.1 + +- id: 1001 + target_id: 1000 + type: comment_up + user_id: 1001 + ip: 192.168.1.11 + +- id: 1002 + target_id: 1000 + type: comment_up + ip: 192.168.1.12 + +- id: 1003 + target_id: 1000 + type: comment_up + ip: 192.168.1.13 + +- id: 1004 + target_id: 1000 + type: comment_down + ip: 192.168.1.14 + +- id: 1005 + target_id: 1000 + type: comment_down + ip: 192.168.1.15 + +- id: 1006 + target_id: 1001 + type: page_down + user_id: 1000 + ip: 127.0.0.1 + +- id: 1007 + target_id: 1001 + type: page_up + ip: 192.168.1.17 + +- id: 1008 + target_id: 1001 + type: page_down + ip: 192.168.1.18 diff --git a/ui/artalk/src/api/v2.ts b/ui/artalk/src/api/v2.ts index 4e837b64..b41505dd 100644 --- a/ui/artalk/src/api/v2.ts +++ b/ui/artalk/src/api/v2.ts @@ -2534,6 +2534,41 @@ export class Api extends HttpClient + this.request< + HandlerResponseVote, + HandlerMap & { + msg?: string + } + >({ + path: `/votes/${targetName}/${targetId}`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }), + /** * @description Create a new vote for a specific comment or page *