add comments

This commit is contained in:
2025-04-24 22:15:30 +02:00
parent cd2116622f
commit ea3faba056
17 changed files with 1688 additions and 48 deletions

View File

@@ -14,7 +14,7 @@ func CouldSeePersonsProfile(ctx context.Context, session neo4j.SessionWithContex
return nil
}
res, err := session.ExecuteRead(ctx, memgraph.GetFamilyTreeById(ctx, xUserID))
res, err := session.ExecuteRead(ctx, memgraph.GetFamilyTreeWithSpousesById(ctx, xUserID))
if err != nil {
return err
}

View File

@@ -0,0 +1,120 @@
package api
import (
"context"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/vcscsvcscs/GenerationsHeritage/apps/db-adapter/internal/api/auth"
"github.com/vcscsvcscs/GenerationsHeritage/apps/db-adapter/internal/memgraph"
"github.com/vcscsvcscs/GenerationsHeritage/apps/db-adapter/pkg/api"
)
func (srv *server) CommentOnPerson(c *gin.Context, id int, params api.CommentOnPersonParams) { //nolint:dupl,lll // This just does not worth abstracting anymore
var comment api.Message
if err := c.ShouldBindJSON(&comment); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"msg": err.Error()})
return
}
session := srv.createSessionWithTimeout(c.Request.Context())
defer closeSession(c.Request.Context(), srv.logger, session, srv.dbOpTimeout)
actx, acancel := context.WithTimeout(c.Request.Context(), srv.dbOpTimeout)
defer acancel()
if err := auth.CouldSeePersonsProfile(actx, session, id, params.XUserID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"msg": fmt.Sprint("User does not have access to this person", err.Error())})
return
}
qctx, qCancel := context.WithTimeout(c.Request.Context(), srv.dbOpTimeout)
defer qCancel()
res, err := session.ExecuteWrite(qctx, memgraph.UpsertCommentOnProfile(
qctx, params.XUserID, id, &comment,
))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"msg": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
func (srv *server) EditComment(c *gin.Context, id int, params api.EditCommentParams) { //nolint:dupl,lll // This just does not worth abstracting anymore
var comment api.Message
if err := c.ShouldBindJSON(&comment); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"msg": err.Error()})
return
}
session := srv.createSessionWithTimeout(c.Request.Context())
defer closeSession(c.Request.Context(), srv.logger, session, srv.dbOpTimeout)
actx, acancel := context.WithTimeout(c.Request.Context(), srv.dbOpTimeout)
defer acancel()
if err := auth.CouldSeePersonsProfile(actx, session, id, params.XUserID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"msg": fmt.Sprint("User does not have access to this person", err.Error())})
return
}
qctx, qCancel := context.WithTimeout(c.Request.Context(), srv.dbOpTimeout)
defer qCancel()
res, err := session.ExecuteWrite(qctx, memgraph.UpsertCommentOnProfile(
qctx, params.XUserID, id, &comment,
))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"msg": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
func (srv *server) DeleteCommentOnPerson(c *gin.Context, id int, params api.DeleteCommentOnPersonParams) {
session := srv.createSessionWithTimeout(c.Request.Context())
defer closeSession(c.Request.Context(), srv.logger, session, srv.dbOpTimeout)
qctx, qCancel := context.WithTimeout(c.Request.Context(), srv.dbOpTimeout)
defer qCancel()
_, err := session.ExecuteWrite(qctx, memgraph.DeleteComment(
qctx, params.XUserID, id,
))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"msg": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "Comment deleted successfully"})
}
func (srv *server) GetCommentsOnPerson(c *gin.Context, id int, params api.GetCommentsOnPersonParams) { //nolint:dupl,lll // This just does not worth abstracting anymore
session := srv.createSessionWithTimeout(c.Request.Context())
defer closeSession(c.Request.Context(), srv.logger, session, srv.dbOpTimeout)
actx, acancel := context.WithTimeout(c.Request.Context(), srv.dbOpTimeout)
defer acancel()
if err := auth.CouldSeePersonsProfile(actx, session, id, params.XUserID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"msg": fmt.Sprint("User does not have access to this person", err.Error())})
return
}
qctx, qCancel := context.WithTimeout(c.Request.Context(), srv.dbOpTimeout)
defer qCancel()
res, err := session.ExecuteRead(qctx, memgraph.GetCommentsOnProfile(qctx, id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"msg": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}

View File

@@ -0,0 +1,164 @@
package api
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
memgraphMock "github.com/vcscsvcscs/GenerationsHeritage/apps/db-adapter/internal/memgraph/mock"
"github.com/vcscsvcscs/GenerationsHeritage/apps/db-adapter/pkg/api"
)
func mockServer(writeErr, authErr error, writeResult any) *server {
mockSession := new(memgraphMock.SessionWithContext)
mockDriver := new(memgraphMock.DriverWithContext)
mockDriver.On("NewSession", mock.Anything, mock.Anything).Return(mockSession)
if authErr != nil {
mockSession.On("ExecuteRead", mock.Anything, mock.Anything, mock.Anything).Return(nil, authErr)
} else {
mockSession.On("ExecuteRead", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
}
if writeErr != nil {
mockSession.On("ExecuteWrite", mock.Anything, mock.Anything, mock.Anything).Return(nil, writeErr)
} else {
mockSession.On("ExecuteWrite", mock.Anything, mock.Anything, mock.Anything).Return(writeResult, nil)
}
mockSession.On("Close", mock.Anything).Return(nil)
return &server{
db: mockDriver,
dbOpTimeout: 2 * time.Second,
}
}
func requestWithBody(method, url, body string) (*gin.Context, *httptest.ResponseRecorder) { //nolint:unparam // could be fixed in the future
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequestWithContext(context.Background(), method, url, bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
return c, w
}
func TestCommentOnPerson(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("Success", func(t *testing.T) {
srv := mockServer(nil, nil, map[string]any{"ok": true})
c, w := requestWithBody(http.MethodPost, "/comment", `{"text": "Hello"}`)
params := api.CommentOnPersonParams{XUserID: 1}
srv.CommentOnPerson(c, 123, params)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "ok")
})
t.Run("Unauthorized", func(t *testing.T) {
srv := mockServer(nil, errors.New("unauthorized"), nil)
c, w := requestWithBody(http.MethodPost, "/comment", `{"text": "Hi"}`)
params := api.CommentOnPersonParams{XUserID: 1}
srv.CommentOnPerson(c, 456, params)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "unauthorized")
})
t.Run("Database error", func(t *testing.T) {
srv := mockServer(errors.New("db error"), nil, nil)
c, w := requestWithBody(http.MethodPost, "/comment", `{"text": "Oops"}`)
params := api.CommentOnPersonParams{XUserID: 2}
srv.CommentOnPerson(c, 789, params)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "db error")
})
}
func TestEditComment(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("Success", func(t *testing.T) {
srv := mockServer(nil, nil, map[string]any{"updated": true})
c, w := requestWithBody(http.MethodPut, "/comment", `{"text": "Updated text"}`)
params := api.EditCommentParams{XUserID: 4}
srv.EditComment(c, 101, params)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "updated")
})
}
func TestDeleteCommentOnPerson(t *testing.T) {
t.Run("Success", func(t *testing.T) {
srv := mockServer(nil, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodDelete, "/comment", http.NoBody)
params := api.DeleteCommentOnPersonParams{XUserID: 5}
srv.DeleteCommentOnPerson(c, 202, params)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Comment deleted")
})
t.Run("DB error", func(t *testing.T) {
srv := mockServer(errors.New("delete error"), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodDelete, "/comment", http.NoBody)
params := api.DeleteCommentOnPersonParams{XUserID: 6}
srv.DeleteCommentOnPerson(c, 303, params)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "delete error")
})
}
func TestGetCommentsOnPerson(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mockSession := new(memgraphMock.SessionWithContext)
mockDriver := new(memgraphMock.DriverWithContext)
mockDriver.On("NewSession", mock.Anything, mock.Anything).Return(mockSession)
mockSession.On("ExecuteRead", mock.Anything, mock.Anything, mock.Anything).Return([]string{"Comment 1"}, nil)
mockSession.On("Close", mock.Anything).Return(nil)
srv := &server{
db: mockDriver,
dbOpTimeout: 2 * time.Second,
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/comment", http.NoBody)
params := api.GetCommentsOnPersonParams{XUserID: 7}
srv.GetCommentsOnPerson(c, 404, params)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Comment 1")
})
t.Run("Unauthorized", func(t *testing.T) {
srv := mockServer(nil, errors.New("access denied"), nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/comment", http.NoBody)
params := api.GetCommentsOnPersonParams{XUserID: 8}
srv.GetCommentsOnPerson(c, 505, params)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "access denied")
})
}

View File

@@ -87,7 +87,7 @@ func (srv *server) CreatePerson(c *gin.Context, params api.CreatePersonParams) {
c.JSON(http.StatusOK, createdPerson)
}
func (srv *server) GetPersonById(c *gin.Context, id int, params api.GetPersonByIdParams) {
func (srv *server) GetPersonById(c *gin.Context, id int, params api.GetPersonByIdParams) { //nolint:dupl,lll // This just does not worth abstracting anymore
session := srv.createSessionWithTimeout(c.Request.Context())
defer closeSession(c.Request.Context(), srv.logger, session, srv.dbOpTimeout)

View File

@@ -0,0 +1,66 @@
package memgraph
import (
"context"
"fmt"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
"github.com/vcscsvcscs/GenerationsHeritage/apps/db-adapter/pkg/api"
)
func UpsertCommentOnProfile(ctx context.Context, commenter, profile int, comment *api.Message) neo4j.ManagedTransactionWork {
convertedComment := StructToMap(comment)
return func(tx neo4j.ManagedTransaction) (any, error) {
result, err := tx.Run(ctx, CommentCypherQuery, map[string]any{
"id1": commenter,
"id2": profile,
"comment": convertedComment,
})
if err != nil {
return nil, err
}
record, err := result.Single(ctx)
if err != nil {
return nil, err
}
return record.AsMap(), nil
}
}
func GetCommentsOnProfile(ctx context.Context, profile int) neo4j.ManagedTransactionWork {
return func(tx neo4j.ManagedTransaction) (any, error) {
result, err := tx.Run(ctx, CommentsOnProfileCypherQuery, map[string]any{
"id": profile,
})
if err != nil {
return nil, err
}
record, err := result.Single(ctx)
if err != nil {
return nil, err
}
return record.AsMap(), nil
}
}
func DeleteComment(ctx context.Context, commenter, profile int) neo4j.ManagedTransactionWork {
return func(tx neo4j.ManagedTransaction) (any, error) {
result, err := tx.Run(ctx, DeleteCommentCypherQuery, map[string]any{
"id1": commenter,
"id2": profile,
})
if err != nil {
return nil, err
}
if result.Peek(ctx) {
return nil, fmt.Errorf("there was a returned value, when deleting admin but there should be none")
}
return nil, nil
}
}

View File

@@ -0,0 +1,197 @@
package memgraph
import (
"context"
"errors"
"testing"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
mmock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/vcscsvcscs/GenerationsHeritage/apps/db-adapter/internal/memgraph/mock"
"github.com/vcscsvcscs/GenerationsHeritage/apps/db-adapter/pkg/api"
)
func TestUpsertCommentOnProfile(t *testing.T) {
ctx := context.Background()
comment := &api.Message{Message: api.StringPtr("Hello!")}
commentMap := StructToMap(comment)
testCases := []struct {
expectedResult map[string]any
mockTxSetup func() *mock.Transaction
expectedError error
name string
}{
{
name: "Successful case",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockResult := new(mock.Result)
mockRecord := &neo4j.Record{Values: []any{"val"}, Keys: []string{"out"}}
mockResult.On("Single", ctx).Return(mockRecord, nil)
mockTx.On("Run", ctx, CommentCypherQuery, map[string]any{"id1": 1, "id2": 2, "comment": commentMap}).Return(mockResult, nil)
return mockTx
},
expectedResult: map[string]any{"out": "val"},
expectedError: nil,
},
{
name: "Run error",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockTx.On("Run", ctx, CommentCypherQuery, mmock.Anything).Return(nil, errors.New("run error"))
return mockTx
},
expectedResult: nil,
expectedError: errors.New("run error"),
},
{
name: "Single error",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockResult := new(mock.Result)
mockResult.On("Single", ctx).Return(nil, errors.New("single error"))
mockTx.On("Run", ctx, CommentCypherQuery, mmock.Anything).Return(mockResult, nil)
return mockTx
},
expectedResult: nil,
expectedError: errors.New("single error"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
work := UpsertCommentOnProfile(ctx, 1, 2, comment)
result, err := work(tc.mockTxSetup())
if tc.expectedError != nil {
require.Error(t, err)
require.Nil(t, result)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectedResult, result)
}
})
}
}
func TestGetCommentsOnProfile(t *testing.T) {
ctx := context.Background()
testCases := []struct {
expectedResult map[string]any
mockTxSetup func() *mock.Transaction
expectedError error
name string
}{
{
name: "Successful case",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockResult := new(mock.Result)
mockRecord := &neo4j.Record{Values: []any{"some"}, Keys: []string{"comments"}}
mockResult.On("Single", ctx).Return(mockRecord, nil)
mockTx.On("Run", ctx, CommentsOnProfileCypherQuery, map[string]any{"id": 2}).Return(mockResult, nil)
return mockTx
},
expectedResult: map[string]any{"comments": "some"},
expectedError: nil,
},
{
name: "Run error",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockTx.On("Run", ctx, CommentsOnProfileCypherQuery, mmock.Anything).Return(nil, errors.New("run error"))
return mockTx
},
expectedResult: nil,
expectedError: errors.New("run error"),
},
{
name: "Single error",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockResult := new(mock.Result)
mockResult.On("Single", ctx).Return(nil, errors.New("single error"))
mockTx.On("Run", ctx, CommentsOnProfileCypherQuery, mmock.Anything).Return(mockResult, nil)
return mockTx
},
expectedResult: nil,
expectedError: errors.New("single error"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
work := GetCommentsOnProfile(ctx, 2)
result, err := work(tc.mockTxSetup())
if tc.expectedError != nil {
require.Error(t, err)
require.Nil(t, result)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectedResult, result)
}
})
}
}
func TestDeleteComment(t *testing.T) {
ctx := context.Background()
testCases := []struct {
mockTxSetup func() *mock.Transaction
expectedError error
name string
}{
{
name: "Successful deletion",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockResult := new(mock.Result)
mockResult.On("Peek", ctx).Return(false)
mockTx.On("Run", ctx, DeleteCommentCypherQuery, map[string]any{"id1": 1, "id2": 2}).Return(mockResult, nil)
return mockTx
},
expectedError: nil,
},
{
name: "Run error",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockTx.On("Run", ctx, DeleteCommentCypherQuery, mmock.Anything).Return(nil, errors.New("run error"))
return mockTx
},
expectedError: errors.New("run error"),
},
{
name: "Peek unexpected return",
mockTxSetup: func() *mock.Transaction {
mockTx := new(mock.Transaction)
mockResult := new(mock.Result)
mockResult.On("Peek", ctx).Return(true)
mockTx.On("Run", ctx, DeleteCommentCypherQuery, mmock.Anything).Return(mockResult, nil)
return mockTx
},
expectedError: errors.New("there was a returned value, when deleting admin but there should be none"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
work := DeleteComment(ctx, 1, 2)
result, err := work(tc.mockTxSetup())
if tc.expectedError != nil {
require.Error(t, err)
require.Nil(t, result)
require.EqualError(t, err, tc.expectedError.Error())
} else {
require.NoError(t, err)
require.Nil(t, result)
}
})
}
}

View File

@@ -148,3 +148,22 @@ var GetBloodRelativesCypherQuery string
//
//go:embed queries/get_family_tree_with_spouses.cypher
var GetFamilyTreeWithSpousesCypherQuery string
// Requires comment, id1 as commenter and id2 as profile that is commented on parameter.
//
// returns people, comments
//
//go:embed queries/comment.cypher
var CommentCypherQuery string
// Requires id1 as commenter and id2 as profile that is commented on parameter.
//
//go:embed queries/delete_comment.cypher
var DeleteCommentCypherQuery string
// Requires id1 as profile that is commented on parameter.
//
// returns comments, people
//
//go:embed queries/comments_on_profile.cypher
var CommentsOnProfileCypherQuery string

View File

@@ -0,0 +1,13 @@
MATCH (a:Person), (b:Person)
WHERE id(a) = $id1 AND id(b) = $id2
MERGE (a)-[r:Comment]->(b)
SET r += $comment
RETURN collect(r) as comments, collect({
id: id(a),
first_name: a.first_name,
middle_name: a.middle_name,
last_name: a.last_name,
born: a.born,
died: a.died,
profile_picture: a.profile_picture
}) as people;

View File

@@ -0,0 +1,12 @@
MATCH (b:Person)
WHERE id(b) = $id
MERGE (a)-[r:Comment]->(b)
RETURN collect(r) as comments, collect({
id: id(a),
first_name: a.first_name,
middle_name: a.middle_name,
last_name: a.last_name,
born: a.born,
died: a.died,
profile_picture: a.profile_picture
}) as people;

View File

@@ -0,0 +1,3 @@
MATCH (a)-[r:Comment]->(b)
WHERE id(a) = $id1 AND id(b) = $id2
DELETE r;