diff --git a/examples/gno.land/r/demo/boards2/board.gno b/examples/gno.land/r/demo/boards2/board.gno index 0df39f7ebfb..16f42f786c5 100644 --- a/examples/gno.land/r/demo/boards2/board.gno +++ b/examples/gno.land/r/demo/boards2/board.gno @@ -64,6 +64,10 @@ func (board *Board) IsPrivate() bool { return board.id == 0 } +func (board *Board) GetID() BoardID { + return board.id +} + // GetURL returns the relative URL of the board. func (board *Board) GetURL() string { return strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") + ":" + board.name diff --git a/examples/gno.land/r/demo/boards2/board_test.gno b/examples/gno.land/r/demo/boards2/board_test.gno new file mode 100644 index 00000000000..9a6c51e8ea9 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/board_test.gno @@ -0,0 +1,158 @@ +package boards + +import ( + "std" + "strings" + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/moul/txlink" +) + +func TestBoardID_String(t *testing.T) { + input := BoardID(32) + + uassert.Equal(t, "32", input.String()) +} + +func TestBoardID_Key(t *testing.T) { + input := BoardID(128) + want := strings.Repeat("0", 7) + "128" + uassert.Equal(t, want, input.Key()) +} + +func TestBoard_IsPrivate(t *testing.T) { + b := new(Board) + b.id = 0 + uassert.True(t, b.IsPrivate()) + + b.id = 128 + uassert.False(t, b.IsPrivate()) +} + +func TestBoard_GetID(t *testing.T) { + want := int(92) + b := new(Board) + b.id = BoardID(want) + got := int(b.GetID()) + + uassert.Equal(t, got, want) + uassert.NotEqual(t, got, want*want) +} + +func TestBoard_GetURL(t *testing.T) { + pkgPath := strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") + name := "foobar_test_get_url123" + want := pkgPath + ":" + name + + var addr std.Address + + board := newBoard(1, name, addr) + got := board.GetURL() + uassert.Equal(t, want, got) +} + +func TestBoard_GetThread(t *testing.T) { + var addr std.Address + b := newBoard(1, "test123", addr) + + _, ok := b.GetThread(12345) + uassert.False(t, ok) + + post := b.AddThread(addr, "foo", "bar") + _, ok = b.GetThread(post.GetPostID()) + uassert.True(t, ok) +} + +func TestBoard_DeleteThread(t *testing.T) { + var addr std.Address + b := newBoard(1, "test123", addr) + + post := b.AddThread(addr, "foo", "bar") + id := post.GetPostID() + + b.DeleteThread(id) + + _, ok := b.GetThread(id) + uassert.False(t, ok) +} + +func TestBoard_HasPermission(t *testing.T) { + var ( + alice std.Address = "012345" + bob std.Address = "cafebabe" + ) + + cases := []struct { + label string + creator std.Address + actor std.Address + perm Permission + expect bool + }{ + { + label: "creator should be able to edit board", + expect: true, + creator: alice, + actor: alice, + perm: PermissionEdit, + }, + { + label: "creator should be able to delete board", + expect: true, + creator: alice, + actor: alice, + perm: PermissionDelete, + }, + { + label: "guest shouldn't be able to edit boards", + expect: false, + creator: alice, + actor: bob, + perm: PermissionEdit, + }, + { + label: "guest shouldn't be able to delete boards", + expect: false, + creator: alice, + actor: bob, + perm: PermissionDelete, + }, + } + + for i, c := range cases { + t.Run(c.label, func(t *testing.T) { + b := newBoard(BoardID(i), "test12345", c.creator) + got := b.HasPermission(c.actor, c.perm) + uassert.Equal(t, c.expect, got) + }) + } +} + +var boardUrlPrefix = strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") + +func TestBoard_GetURLFromThreadID(t *testing.T) { + boardName := "test12345" + b := newBoard(BoardID(11), boardName, "") + want := boardUrlPrefix + ":" + boardName + "/10" + + got := b.GetURLFromThreadID(10) + uassert.Equal(t, want, got) +} + +func TestBoard_GetURLFromReplyID(t *testing.T) { + boardName := "test12345" + b := newBoard(BoardID(11), boardName, "") + want := boardUrlPrefix + ":" + boardName + "/10/20" + + got := b.GetURLFromReplyID(10, 20) + uassert.Equal(t, want, got) +} + +func TestBoard_GetPostFormURL(t *testing.T) { + bid := BoardID(386) + b := newBoard(bid, "foo1234", "") + expect := txlink.URL("CreateThread", "bid", bid.String()) + got := b.GetPostFormURL() + uassert.Equal(t, expect, got) +} diff --git a/examples/gno.land/r/demo/boards2/post.gno b/examples/gno.land/r/demo/boards2/post.gno index 5f9ceae2f5e..a2a19a1b618 100644 --- a/examples/gno.land/r/demo/boards2/post.gno +++ b/examples/gno.land/r/demo/boards2/post.gno @@ -1,6 +1,7 @@ package boards import ( + "errors" "std" "strconv" "time" @@ -24,35 +25,35 @@ func (id PostID) Key() string { // A Post is a "thread" or a "reply" depending on context. // A thread is a Post of a Board that holds other replies. type Post struct { - board *Board - id PostID - creator std.Address - title string // optional - body string - replies avl.Tree // Post.id -> *Post - repliesAll avl.Tree // Post.id -> *Post (all replies, for top-level posts) - reposts avl.Tree // Board.id -> Post.id - threadID PostID // original Post.id - parentID PostID // parent Post.id (if reply or repost) - repostBoard BoardID // original Board.id (if repost) - createdAt time.Time - updatedAt time.Time + board *Board + id PostID + creator std.Address + title string // optional + body string + replies avl.Tree // Post.id -> *Post + repliesAll avl.Tree // Post.id -> *Post (all replies, for top-level posts) + reposts avl.Tree // Board.id -> Post.id + threadID PostID // original Post.id + parentID PostID // parent Post.id (if reply or repost) + repostBoardID BoardID // original Board.id (if repost) + createdAt time.Time + updatedAt time.Time } -func newPost(board *Board, id PostID, creator std.Address, title, body string, threadID, parentID PostID, repostBoard BoardID) *Post { +func newPost(board *Board, id PostID, creator std.Address, title, body string, threadID, parentID PostID, repostBoardID BoardID) *Post { return &Post{ - board: board, - id: id, - creator: creator, - title: title, - body: body, - replies: avl.Tree{}, - repliesAll: avl.Tree{}, - reposts: avl.Tree{}, - threadID: threadID, - parentID: parentID, - repostBoard: repostBoard, - createdAt: time.Now(), + board: board, + id: id, + creator: creator, + title: title, + body: body, + replies: avl.Tree{}, + repliesAll: avl.Tree{}, + reposts: avl.Tree{}, + threadID: threadID, + parentID: parentID, + repostBoardID: repostBoardID, + createdAt: time.Now(), } } @@ -60,10 +61,42 @@ func (post *Post) IsThread() bool { return post.parentID == 0 } +func (post *Post) GetBoard() *Board { + return post.board +} + func (post *Post) GetPostID() PostID { return post.id } +func (post *Post) GetParentID() PostID { + return post.parentID +} + +func (post *Post) GetRepostBoardID() BoardID { + return post.repostBoardID +} + +func (post *Post) GetCreator() std.Address { + return post.creator +} + +func (post *Post) GetTitle() string { + return post.title +} + +func (post *Post) GetBody() string { + return post.body +} + +func (post *Post) GetCreatedAt() time.Time { + return post.createdAt +} + +func (post *Post) GetUpdatedAt() time.Time { + return post.updatedAt +} + func (post *Post) AddReply(creator std.Address, body string) *Post { board := post.board pid := board.incGetPostID() @@ -108,24 +141,30 @@ func (post *Post) AddRepostTo(creator std.Address, title, body string, dst *Boar return repost } -func (thread *Post) DeleteReply(replyID PostID) { - if thread.id == replyID { - panic("should not happen") +func (post *Post) DeleteReply(replyID PostID) error { + if !post.IsThread() { + // TODO: Allow removing replies from parent replies too + panic("cannot delete reply from a non-thread post") + } + + if post.id == replyID { + return errors.New("expected an ID of an inner reply") } key := replyID.Key() - v, removed := thread.repliesAll.Remove(key) + v, removed := post.repliesAll.Remove(key) if !removed { - panic("reply not found in thread") + return errors.New("reply not found in thread") } - post := v.(*Post) - if post.parentID != thread.id { - parent, _ := thread.GetReply(post.parentID) + reply := v.(*Post) + if reply.parentID != post.id { + parent, _ := post.GetReply(reply.parentID) parent.replies.Remove(key) } else { - thread.replies.Remove(key) + post.replies.Remove(key) } + return nil } // TODO: Change HasPermission to use a new authorization interface's `CanDo()` @@ -158,15 +197,15 @@ func (post *Post) GetURL() string { func (post *Post) GetReplyFormURL() string { return txlink.URL("CreateReply", "bid", post.board.id.String(), - "threadid", post.threadID.String(), - "postid", post.id.String(), + "threadID", post.threadID.String(), + "postID", post.id.String(), ) } func (post *Post) GetRepostFormURL() string { return txlink.URL("CreateRepost", "bid", post.board.id.String(), - "postid", post.id.String(), + "postID", post.id.String(), ) } @@ -185,10 +224,10 @@ func (post *Post) GetDeleteFormURL() string { } func (post *Post) RenderSummary() string { - if post.repostBoard != 0 { - dstBoard, found := getBoard(post.repostBoard) + if post.repostBoardID != 0 { + dstBoard, found := getBoard(post.repostBoardID) if !found { - panic("repostBoard does not exist") + panic("repost board does not exist") } thread, found := dstBoard.GetThread(PostID(post.parentID)) diff --git a/examples/gno.land/r/demo/boards2/post_test.gno b/examples/gno.land/r/demo/boards2/post_test.gno new file mode 100644 index 00000000000..f4ec444f0cc --- /dev/null +++ b/examples/gno.land/r/demo/boards2/post_test.gno @@ -0,0 +1,375 @@ +package boards + +import ( + "strings" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" +) + +func TestPostUpdate(t *testing.T) { + board := newBoard(1, "test123", testutils.TestAddress("creator")) + creator := testutils.TestAddress("creator") + post := newPost(board, 1, creator, "Title", "Body", 1, 0, 0) + title := "New Title" + body := "New body" + + post.Update(title, body) + + uassert.Equal(t, title, post.GetTitle()) + uassert.Equal(t, body, post.GetBody()) + uassert.False(t, post.GetUpdatedAt().IsZero()) +} + +func TestPostAddRepostTo(t *testing.T) { + cases := []struct { + name, title, body string + dstBoard *Board + thread *Post + setup func() *Post + err string + }{ + { + name: "repost thread", + title: "Repost Title", + body: "Repost body", + dstBoard: newBoard(42, "dst123", testutils.TestAddress("creatorDstBoard")), + setup: func() *Post { return createTestThread(t) }, + }, + { + name: "invalid repost from reply", + setup: func() *Post { return createTestReply(t) }, + err: "cannot repost non-thread post", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ( + repost *Post + creator = testutils.TestAddress("repostCreator") + thread = tc.setup() + ) + + createRepost := func() { + repost = thread.AddRepostTo(creator, tc.title, tc.body, tc.dstBoard) + } + + if tc.err != "" { + uassert.PanicsWithMessage(t, tc.err, createRepost) + return + } else { + uassert.NotPanics(t, createRepost) + } + + r, found := tc.dstBoard.GetThread(repost.GetPostID()) + uassert.True(t, found) + uassert.True(t, repost == r) + uassert.Equal(t, tc.title, repost.GetTitle()) + uassert.Equal(t, tc.body, repost.GetBody()) + uassert.Equal(t, uint(thread.GetBoard().GetID()), uint(repost.GetRepostBoardID())) + }) + } +} + +func TestNewThread(t *testing.T) { + creator := testutils.TestAddress("creator") + member := testutils.TestAddress("member") + title := "Test Title" + body := strings.Repeat("A", 82) + boardID := BoardID(1) + threadID := PostID(42) + boardName := "test123" + board := newBoard(boardID, boardName, creator) + url := ufmt.Sprintf( + "/r/demo/boards2:%s/%d", + boardName, + uint(threadID), + ) + replyURL := ufmt.Sprintf( + "/r/demo/boards2$help&func=CreateReply&bid=%d&threadID=%d&postID=%d", + uint(boardID), + uint(threadID), + uint(threadID), + ) + repostURL := ufmt.Sprintf( + "/r/demo/boards2$help&func=CreateRepost&bid=%d&postID=%d", + uint(boardID), + uint(threadID), + ) + deleteURL := ufmt.Sprintf( + "/r/demo/boards2$help&func=DeleteThread&bid=%d&threadID=%d", + uint(boardID), + uint(threadID), + ) + + thread := newPost(board, threadID, creator, title, body, threadID, 0, 0) + + uassert.True(t, thread.IsThread()) + uassert.Equal(t, uint(threadID), uint(thread.GetPostID())) + uassert.False(t, thread.GetCreatedAt().IsZero()) + uassert.True(t, thread.GetUpdatedAt().IsZero()) + uassert.Equal(t, title, thread.GetTitle()) + uassert.Equal(t, body[:77]+"...", thread.GetSummary()) + uassert.Equal(t, url, thread.GetURL()) + uassert.Equal(t, replyURL, thread.GetReplyFormURL()) + uassert.Equal(t, repostURL, thread.GetRepostFormURL()) + uassert.Equal(t, deleteURL, thread.GetDeleteFormURL()) + uassert.True(t, thread.HasPermission(creator, PermissionEdit)) + uassert.True(t, thread.HasPermission(creator, PermissionDelete)) + uassert.False(t, thread.HasPermission(creator, Permission("unknown"))) + uassert.False(t, thread.HasPermission(member, PermissionEdit)) + uassert.False(t, thread.HasPermission(member, PermissionDelete)) + uassert.False(t, thread.HasPermission(member, Permission("unknown"))) +} + +func TestThreadAddReply(t *testing.T) { + replier := testutils.TestAddress("replier") + thread := createTestThread(t) + threadID := uint(thread.GetPostID()) + body := "A reply" + + reply := thread.AddReply(replier, body) + + r, found := thread.GetReply(reply.GetPostID()) + uassert.True(t, found) + uassert.True(t, reply == r) + uassert.Equal(t, threadID+1, uint(reply.GetPostID())) + uassert.Equal(t, reply.GetCreator(), replier) + uassert.Equal(t, reply.GetBody(), body) +} + +func TestThreadGetReply(t *testing.T) { + cases := []struct { + name string + thread *Post + setup func(thread *Post) (replyID PostID) + found bool + }{ + { + name: "found", + thread: createTestThread(t), + setup: func(thread *Post) PostID { + reply := thread.AddReply(testutils.TestAddress("replier"), "") + return reply.GetPostID() + }, + found: true, + }, + { + name: "not found", + thread: createTestThread(t), + setup: func(*Post) PostID { return 42 }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + replyID := tc.setup(tc.thread) + + reply, found := tc.thread.GetReply(replyID) + + uassert.Equal(t, tc.found, found) + if reply != nil { + uassert.Equal(t, uint(replyID), uint(reply.GetPostID())) + } + }) + } +} + +func TestThreadDeleteReply(t *testing.T) { + thread := createTestThread(t) + cases := []struct { + name string + setup func() PostID + err string + }{ + { + name: "ok", + setup: func() PostID { + reply := thread.AddReply(testutils.TestAddress("replier"), "") + return reply.GetPostID() + }, + }, + { + name: "ok nested", + setup: func() PostID { + reply := thread.AddReply(testutils.TestAddress("replier"), "") + return reply.AddReply(testutils.TestAddress("replier2"), "").GetPostID() + }, + }, + { + name: "invalid", + setup: func() PostID { return thread.GetPostID() }, + err: "expected an ID of an inner reply", + }, + { + name: "not found", + setup: func() PostID { return 42 }, + err: "reply not found in thread", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + replyID := tc.setup() + + err := thread.DeleteReply(replyID) + + if tc.err != "" { + uassert.ErrorContains(t, err, tc.err) + return + } + + uassert.NoError(t, err) + _, found := thread.GetReply(replyID) + uassert.False(t, found) + }) + } +} + +func TestThreadRenderSummary(t *testing.T) { + t.Skip("TODO: implement") +} + +func TestThreadRender(t *testing.T) { + t.Skip("TODO: implement") +} + +func TestThreadRenderInner(t *testing.T) { + t.Skip("TODO: implement") +} + +func TestNewReply(t *testing.T) { + creator := testutils.TestAddress("creator") + member := testutils.TestAddress("member") + body := strings.Repeat("A", 82) + boardID := BoardID(1) + threadID := PostID(42) + parentID := PostID(1) + replyID := PostID(2) + boardName := "test123" + board := newBoard(boardID, boardName, creator) + url := ufmt.Sprintf( + "/r/demo/boards2:%s/%d/%d", + boardName, + uint(threadID), + uint(replyID), + ) + replyURL := ufmt.Sprintf( + "/r/demo/boards2$help&func=CreateReply&bid=%d&threadID=%d&postID=%d", + uint(boardID), + uint(threadID), + uint(replyID), + ) + deleteURL := ufmt.Sprintf( + "/r/demo/boards2$help&func=DeleteReply&bid=%d&threadID=%d&replyID=%d", + uint(boardID), + uint(threadID), + uint(replyID), + ) + + reply := newPost(board, replyID, creator, "", body, threadID, parentID, 0) + + uassert.False(t, reply.IsThread()) + uassert.Equal(t, uint(replyID), uint(reply.GetPostID())) + uassert.False(t, reply.GetCreatedAt().IsZero()) + uassert.True(t, reply.GetUpdatedAt().IsZero()) + uassert.Equal(t, body[:77]+"...", reply.GetSummary()) + uassert.Equal(t, url, reply.GetURL()) + uassert.Equal(t, replyURL, reply.GetReplyFormURL()) + uassert.Equal(t, deleteURL, reply.GetDeleteFormURL()) + uassert.True(t, reply.HasPermission(creator, PermissionEdit)) + uassert.True(t, reply.HasPermission(creator, PermissionDelete)) + uassert.False(t, reply.HasPermission(creator, Permission("unknown"))) + uassert.False(t, reply.HasPermission(member, PermissionEdit)) + uassert.False(t, reply.HasPermission(member, PermissionDelete)) + uassert.False(t, reply.HasPermission(member, Permission("unknown"))) +} + +func TestReplyAddReply(t *testing.T) { + replier := testutils.TestAddress("replier") + thread := createTestThread(t) + parentReply := thread.AddReply(testutils.TestAddress("parentReplier"), "") + threadID := uint(thread.GetPostID()) + parentReplyID := uint(parentReply.GetPostID()) + body := "A child reply" + + reply := parentReply.AddReply(replier, body) + + r, found := thread.GetReply(reply.GetPostID()) + uassert.True(t, found) + uassert.True(t, reply == r) + uassert.Equal(t, parentReplyID, uint(reply.GetParentID())) + uassert.Equal(t, parentReplyID+1, uint(reply.GetPostID())) + uassert.Equal(t, reply.GetCreator(), replier) + uassert.Equal(t, reply.GetBody(), body) +} + +func TestReplyGetReply(t *testing.T) { + thread := createTestThread(t) + parentReply := thread.AddReply(testutils.TestAddress("parentReplier"), "") + cases := []struct { + name string + setup func() PostID + found bool + }{ + { + name: "found", + setup: func() PostID { + reply := parentReply.AddReply(testutils.TestAddress("replier"), "") + return reply.GetPostID() + }, + found: true, + }, + { + name: "not found", + setup: func() PostID { return 42 }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + replyID := tc.setup() + + reply, found := thread.GetReply(replyID) + + uassert.Equal(t, tc.found, found) + if reply != nil { + uassert.Equal(t, uint(replyID), uint(reply.GetPostID())) + } + }) + } +} + +func TestReplyDeleteReply(t *testing.T) { + thread := createTestThread(t) + parentReply := thread.AddReply(testutils.TestAddress("replier"), "") + reply := parentReply.AddReply(testutils.TestAddress("replier2"), "") + + // NOTE: Deleting a reply from a parent reply should eventually be suported + uassert.PanicsWithMessage(t, "cannot delete reply from a non-thread post", func() { + parentReply.DeleteReply(reply.GetPostID()) + }) +} + +func TestReplyRender(t *testing.T) { + t.Skip("TODO: implement") +} + +func createTestThread(t *testing.T) *Post { + t.Helper() + + creator := testutils.TestAddress("creator") + board := newBoard(1, "test_board_123", creator) + return board.AddThread(creator, "Title", "Body") +} + +func createTestReply(t *testing.T) *Post { + t.Helper() + + creator := testutils.TestAddress("replier") + thread := createTestThread(t) + return thread.AddReply(creator, "Test message") +}