diff --git a/backend/_example/rpc/main.go b/backend/_example/rpc/main.go
new file mode 100644
index 00000000..de668734
--- /dev/null
+++ b/backend/_example/rpc/main.go
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2019 Umputun. All rights reserved.
+ * Use of this source code is governed by a MIT-style
+ * license that can be found in the LICENSE file.
+ */
+
+package main
+
+// Opts with all cli commands and flags
+var opts struct {
+ API string `long:"api" env:"API" default:"/" description:"api root url"`
+ Port int `long:"port" env:"PORT" default:"8080" description:"rpc server port"`
+ AuthUser string `long:"auth-user" env:"AUTH_USER" default:"" description:"auth user name"`
+ AuthPasswd string `long:"auth-passwd" env:"AUTH_PASSWD" default:"" description:"auth password"`
+ Secret string `long:"secret" env:"SECRET" required:"true" description:"secret key"`
+ Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
+}
+
+
+func main() {
+
+}
diff --git a/backend/_example/rpc/plugin/admin.go b/backend/_example/rpc/plugin/admin.go
new file mode 100644
index 00000000..6f457199
--- /dev/null
+++ b/backend/_example/rpc/plugin/admin.go
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2019 Umputun. All rights reserved.
+ * Use of this source code is governed by a MIT-style
+ * license that can be found in the LICENSE file.
+ */
+
+package plugin
+
+import (
+ log "github.com/go-pkgz/lgr"
+ "github.com/pkg/errors"
+)
+
+// MemAdminStore implements admin.Store with mongo backend
+type MemAdminStore struct {
+ data map[string]AdminRec
+ key string
+}
+
+// AdminRec is a records per site with all admin info in
+type AdminRec struct {
+ SiteID string `bson:"site"`
+ IDs []string `bson:"ids"`
+ Email string `bson:"email"`
+}
+
+const mongoAdmin = "admin"
+
+// NewMemAdminStore makes admin Store in memory
+func NewMemAdminStore(key string) *MemAdminStore {
+ log.Print("[DEBUG] make memory admin store")
+ return &MemAdminStore{data: map[string]AdminRec{}, key: key}
+}
+
+// Key executes find by siteID and returns substructure with secret key
+func (m *MemAdminStore) Key() (key string, err error) {
+ return m.key, nil
+}
+
+// Admins executes find by siteID and returns admins ids
+func (m *MemAdminStore) Admins(siteID string) (ids []string, err error) {
+ resp, ok := m.data[siteID]
+ if !ok {
+ return nil, errors.Errorf("site %s not found", siteID)
+ }
+
+ return resp.IDs, nil
+}
+
+// Email executes find by siteID and returns admin's email
+func (m *MemAdminStore) Email(siteID string) (email string, err error) {
+ resp, ok := m.data[siteID]
+ if !ok {
+ return "", errors.Errorf("site %s not found", siteID)
+ }
+
+ return resp.Email, nil
+}
diff --git a/backend/_example/rpc/plugin/admin_test.go b/backend/_example/rpc/plugin/admin_test.go
new file mode 100644
index 00000000..1fcb46e8
--- /dev/null
+++ b/backend/_example/rpc/plugin/admin_test.go
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2019 Umputun. All rights reserved.
+ * Use of this source code is governed by a MIT-style
+ * license that can be found in the LICENSE file.
+ */
+
+package plugin
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/umputun/remark/backend/app/store/admin"
+)
+
+func TestMongoStore_Get(t *testing.T) {
+
+ adm := NewMemAdminStore("secret")
+ var ms admin.Store = adm
+
+ adm.data = map[string]AdminRec{
+ "site1": {"site1", []string{"i11", "i12"}, "e1"},
+ "site2": {"site2", []string{"i21", "i22"}, "e2"},
+ }
+
+ admins, err := ms.Admins("site1")
+ assert.NoError(t, err)
+ assert.Equal(t, []string{"i11", "i12"}, admins)
+ email, err := ms.Email("site1")
+ assert.NoError(t, err)
+ assert.Equal(t, "e1", email)
+ key, err := ms.Key()
+ assert.NoError(t, err)
+ assert.Equal(t, "secret", key)
+
+ admins, err = ms.Admins("site2")
+ assert.NoError(t, err)
+ assert.Equal(t, []string{"i21", "i22"}, admins)
+ email, err = ms.Email("site2")
+ assert.NoError(t, err)
+ assert.Equal(t, "e2", email)
+ key, err = ms.Key()
+ assert.NoError(t, err)
+ assert.Equal(t, "secret", key)
+
+ admins, err = ms.Admins("no-site-in-db")
+ assert.EqualError(t, err, "site no-site-in-db not found")
+
+ email, err = ms.Email("no-site-in-db")
+ assert.EqualError(t, err, "site no-site-in-db not found")
+}
diff --git a/backend/_example/rpc/plugin/engine.go b/backend/_example/rpc/plugin/engine.go
new file mode 100644
index 00000000..ed892ded
--- /dev/null
+++ b/backend/_example/rpc/plugin/engine.go
@@ -0,0 +1,436 @@
+/*
+ * Copyright 2019 Umputun. All rights reserved.
+ * Use of this source code is governed by a MIT-style
+ * license that can be found in the LICENSE file.
+ */
+
+package plugin
+
+import (
+ "log"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/pkg/errors"
+
+ "github.com/umputun/remark/backend/app/store/engine"
+
+ "github.com/umputun/remark/backend/app/store"
+)
+
+const lastLimit = 1000
+
+// MemEngine implements in-memory engine interface
+type MemEngine struct {
+ posts map[string][]store.Comment // key is siteID
+ metaUsers map[string]metaUser // key is userID
+ metaPosts map[store.Locator]metaPost // key is post's locator
+ sync.RWMutex
+}
+
+type metaPost struct {
+ PostURL string
+ SiteID string
+ ReadOnly bool
+}
+
+type metaUser struct {
+ UserID string
+ SiteID string
+ Verified bool
+ Blocked bool
+ BlockedUntil time.Time
+}
+
+// NewMemEngine makes in-memory engine.
+func NewMemEngine() *MemEngine {
+
+ result := &MemEngine{
+ posts: map[string][]store.Comment{},
+ metaUsers: map[string]metaUser{},
+ metaPosts: map[store.Locator]metaPost{},
+ }
+ return result
+}
+
+// Create new comment, write can be buffered and delayed.
+func (m *MemEngine) Create(comment store.Comment) (commentID string, err error) {
+
+ if ro, e := m.Flag(engine.FlagRequest{Flag: engine.ReadOnly, Locator: comment.Locator}); e == nil && ro {
+ return "", errors.Errorf("post %s is read-only", comment.Locator.URL)
+ }
+
+ m.Lock()
+ defer m.Unlock()
+ comments := m.posts[comment.Locator.SiteID]
+ for _, c := range comments {
+ if c.ID == comment.ID {
+ return "", errors.New("dup key")
+ }
+ }
+ comments = append(comments, comment)
+ m.posts[comment.Locator.SiteID] = comments
+ return comment.ID, nil
+}
+
+// Find returns all comments for post and sorts results
+func (m *MemEngine) Find(req engine.FindRequest) (comments []store.Comment, err error) {
+ m.RLock()
+ defer m.RUnlock()
+
+ comments = []store.Comment{}
+
+ if req.Sort == "" {
+ req.Sort = "time"
+ }
+
+ switch {
+
+ case req.Locator.SiteID != "" && req.Locator.URL != "": // find comments for site and url
+ comments = m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
+ return c.Locator == req.Locator
+ })
+
+ case req.Locator.SiteID != "" && req.Locator.URL == "" && req.UserID == "": // find last comments for site
+ if req.Limit > lastLimit || req.Limit == 0 {
+ req.Limit = lastLimit
+ }
+ if req.Since.IsZero() {
+ req.Since = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
+ }
+
+ comments = m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
+ return !c.Deleted && c.Timestamp.After(req.Since)
+ })
+ comments = engine.SortComments(comments, "-time")
+ if len(comments) > req.Limit {
+ comments = comments[:req.Limit]
+ }
+ return comments, nil
+
+ case req.Locator.SiteID != "" && req.UserID != "": // find comments for user
+ comments = m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
+ return c.User.ID == req.UserID
+ })
+ }
+ comments = engine.SortComments(comments, req.Sort)
+ if req.Skip > 0 && req.Skip > len(comments) {
+ return []store.Comment{}, nil
+ }
+ if req.Skip > 0 && req.Skip < len(comments) {
+ comments = comments[req.Skip:]
+ }
+
+ if req.Limit > 0 && req.Limit < len(comments) {
+ comments = comments[:req.Limit]
+ }
+
+ return comments, err
+}
+
+// Get returns comment for locator.URL and commentID string
+func (m *MemEngine) Get(req engine.GetRequest) (comment store.Comment, err error) {
+ m.RLock()
+ defer m.RUnlock()
+ return m.get(req.Locator, req.CommentID)
+}
+
+// Update updates comment for locator.URL with mutable part of comment
+func (m *MemEngine) Update(comment store.Comment) error {
+ m.Lock()
+ defer m.Unlock()
+ return m.updateComment(comment)
+}
+
+// Count returns number of comments for post or user
+func (m *MemEngine) Count(req engine.FindRequest) (count int, err error) {
+ m.RLock()
+ defer m.RUnlock()
+
+ switch {
+ case req.Locator.URL != "": // comment's count for post
+ comments := m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
+ return c.Locator == req.Locator && !c.Deleted
+ })
+ return len(comments), nil
+ case req.UserID != "":
+ comments := m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
+ return c.User.ID == req.UserID && !c.Deleted
+ })
+ return len(comments), nil
+ default:
+ return 0, errors.Errorf("invalid count request %+v", req)
+ }
+}
+
+// Info get post(s) meta info
+func (m *MemEngine) Info(req engine.InfoRequest) (res []store.PostInfo, err error) {
+ m.RLock()
+ defer m.RUnlock()
+ res = []store.PostInfo{}
+
+ if req.Locator.URL != "" { // post info
+ comments := m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
+ return c.Locator == req.Locator
+ })
+ if len(comments) == 0 {
+ return nil, errors.New("not found")
+ }
+ info := store.PostInfo{
+ URL: req.Locator.URL,
+ Count: len(comments),
+ ReadOnly: false,
+ FirstTS: comments[0].Timestamp.UTC(),
+ LastTS: comments[len(comments)-1].Timestamp.UTC(),
+ }
+ // set read-only from age and manual bucket
+ info.ReadOnly = req.ReadOnlyAge > 0 && !info.FirstTS.IsZero() &&
+ info.FirstTS.AddDate(0, 0, req.ReadOnlyAge).Before(time.Now())
+ if !info.ReadOnly {
+ v := m.checkFlag(engine.FlagRequest{Flag: engine.ReadOnly, Locator: req.Locator})
+ info.ReadOnly = v
+ }
+ return []store.PostInfo{info}, nil
+ }
+
+ if req.Locator.URL == "" && req.Locator.SiteID != "" { // site info (list)
+ if req.Limit <= 0 {
+ req.Limit = 1000
+ }
+ if req.Skip < 0 {
+ req.Skip = 0
+ }
+
+ infoAll := map[store.Locator]store.PostInfo{}
+ for _, c := range m.posts[req.Locator.SiteID] {
+ var info store.PostInfo
+ var ok bool
+ if info, ok = infoAll[c.Locator]; !ok {
+ info = store.PostInfo{URL: c.Locator.URL, FirstTS: c.Timestamp.UTC()}
+ }
+ info.Count++
+ info.LastTS = c.Timestamp.UTC()
+ infoAll[c.Locator] = info
+ }
+
+ n := 0
+ for _, v := range infoAll {
+ n++
+ if len(res) >= req.Limit {
+ break
+ }
+ if req.Skip > 0 && n <= req.Skip {
+ continue
+ }
+ res = append(res, v)
+ }
+ sort.Slice(res, func(i, j int) bool {
+ return res[i].URL > res[j].URL
+ })
+ return res, nil
+ }
+
+ return nil, errors.Errorf("invalid info request %+v", req)
+}
+
+// Flag sets and gets flag values
+func (m *MemEngine) Flag(req engine.FlagRequest) (val bool, err error) {
+ m.Lock()
+ defer m.Unlock()
+
+ if req.Update == engine.FlagNonSet { // read flag value, no update requested
+ return m.checkFlag(req), nil
+ }
+ // write flag value
+ return m.setFlag(req)
+}
+
+// ListFlags get list of flagged keys, like blocked & verified user
+// works for full locator (post flags) or with userID
+func (m *MemEngine) ListFlags(req engine.FlagRequest) (res []interface{}, err error) {
+ m.RLock()
+ defer m.RUnlock()
+
+ res = []interface{}{}
+
+ switch req.Flag {
+ case engine.Verified:
+ for _, u := range m.metaUsers {
+ if u.SiteID == req.Locator.SiteID {
+ res = append(res, u.UserID)
+ }
+ }
+ return res, nil
+
+ case engine.Blocked:
+ log.Printf("%+v", m.metaUsers)
+ for _, u := range m.metaUsers {
+ if u.SiteID == req.Locator.SiteID && u.Blocked && u.BlockedUntil.After(time.Now()) {
+ res = append(res, store.BlockedUser{ID: u.UserID, Until: u.BlockedUntil})
+ }
+ }
+ return res, nil
+ }
+
+ return nil, errors.Errorf("flag %s not listable", req.Flag)
+}
+
+// Delete post(s) by id or by userID
+func (m *MemEngine) Delete(req engine.DeleteRequest) error {
+
+ m.Lock()
+ defer m.Unlock()
+
+ switch {
+ case req.Locator.URL != "" && req.CommentID != "": // delete comment
+ return m.deleteComment(req.Locator, req.CommentID, req.DeleteMode)
+
+ case req.Locator.SiteID != "" && req.UserID != "" && req.CommentID == "": // delete user
+ comments := m.match(m.posts[req.Locator.SiteID], func(c store.Comment) bool {
+ return c.User.ID == req.UserID && !c.Deleted
+ })
+ for _, c := range comments {
+ if e := m.deleteComment(c.Locator, c.ID, req.DeleteMode); e != nil {
+ return e
+ }
+ }
+ return nil
+
+ case req.Locator.SiteID != "" && req.Locator.URL == "" && req.CommentID == "" && req.UserID == "": // delete site
+ if _, ok := m.posts[req.Locator.SiteID]; !ok {
+ return errors.New("not found")
+ }
+ m.posts[req.Locator.SiteID] = []store.Comment{}
+ return nil
+ }
+
+ return errors.Errorf("invalid delete request %+v", req)
+}
+
+func (m *MemEngine) deleteComment(loc store.Locator, id string, mode store.DeleteMode) error {
+
+ comments := m.match(m.posts[loc.SiteID], func(c store.Comment) bool {
+ return c.Locator == loc && c.ID == id
+ })
+ if len(comments) == 0 {
+ return errors.New("not found")
+ }
+
+ comments[0].SetDeleted(mode)
+ return m.updateComment(comments[0])
+}
+
+// Close store
+func (m *MemEngine) Close() error {
+ return nil
+}
+
+func (m *MemEngine) checkFlag(req engine.FlagRequest) (val bool) {
+ switch req.Flag {
+ case engine.Blocked:
+ if meta, ok := m.metaUsers[req.UserID]; ok {
+ if meta.SiteID != req.Locator.SiteID {
+ return false
+ }
+ return meta.Blocked && meta.BlockedUntil.After(time.Now())
+ }
+ case engine.Verified:
+ if meta, ok := m.metaUsers[req.UserID]; ok {
+ if meta.SiteID != req.Locator.SiteID {
+ return false
+ }
+ return meta.Verified
+ }
+ case engine.ReadOnly:
+ if meta, ok := m.metaPosts[req.Locator]; ok {
+ return meta.ReadOnly
+ }
+ }
+ return false
+}
+
+func (m *MemEngine) setFlag(req engine.FlagRequest) (res bool, err error) {
+
+ status := false
+ if req.Update == engine.FlagTrue {
+ status = true
+ }
+
+ switch req.Flag {
+
+ case engine.Blocked:
+ until := time.Time{}
+ if status {
+ until = time.Now().AddDate(100, 0, 0) // permanent is 100years
+ if req.TTL > 0 {
+ until = time.Now().Add(req.TTL)
+ }
+ }
+ meta := m.metaUsers[req.UserID]
+ meta = metaUser{
+ UserID: req.UserID,
+ SiteID: req.Locator.SiteID,
+ Blocked: status,
+ BlockedUntil: until,
+ }
+ m.metaUsers[req.UserID] = meta
+
+ case engine.Verified:
+ meta := m.metaUsers[req.UserID]
+ meta = metaUser{
+ UserID: req.UserID,
+ SiteID: req.Locator.SiteID,
+ Verified: status,
+ }
+ m.metaUsers[req.UserID] = meta
+
+ case engine.ReadOnly:
+ info, ok := m.metaPosts[req.Locator]
+ if !ok {
+ info.SiteID = req.Locator.SiteID
+ info.PostURL = req.Locator.URL
+ }
+ info.ReadOnly = status
+ m.metaPosts[req.Locator] = info
+ }
+ return status, errors.Wrapf(err, "failed to set flag %+v", req)
+}
+
+func (m *MemEngine) get(loc store.Locator, commentID string) (store.Comment, error) {
+ comments := m.match(m.posts[loc.SiteID], func(c store.Comment) bool {
+ return c.Locator == loc && c.ID == commentID
+ })
+ if len(comments) == 0 {
+ return store.Comment{}, errors.New("not found")
+ }
+ return comments[0], nil
+}
+
+func (m *MemEngine) updateComment(comment store.Comment) error {
+ comments := m.posts[comment.Locator.SiteID]
+ for i, c := range comments {
+ if c.ID == comment.ID && c.Locator == comment.Locator {
+ c.Text = comment.Text
+ c.Orig = comment.Orig
+ c.Score = comment.Score
+ c.Votes = comment.Votes
+ c.Pin = comment.Pin
+ c.Deleted = comment.Deleted
+ c.User = comment.User
+ comments[i] = c
+ m.posts[comment.Locator.SiteID] = comments
+ return nil
+ }
+ }
+ return errors.New("not found")
+}
+
+func (m *MemEngine) match(comments []store.Comment, fn func(c store.Comment) bool) (res []store.Comment) {
+ for _, c := range comments {
+ if fn(c) {
+ res = append(res, c)
+ }
+ }
+ return res
+}
diff --git a/backend/_example/rpc/plugin/engine_test.go b/backend/_example/rpc/plugin/engine_test.go
new file mode 100644
index 00000000..6ff3a6ee
--- /dev/null
+++ b/backend/_example/rpc/plugin/engine_test.go
@@ -0,0 +1,725 @@
+/*
+ * Copyright 2019 Umputun. All rights reserved.
+ * Use of this source code is governed by a MIT-style
+ * license that can be found in the LICENSE file.
+ */
+
+package plugin
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/umputun/remark/backend/app/store"
+ "github.com/umputun/remark/backend/app/store/engine"
+)
+
+func TestMem_CreateAndFind(t *testing.T) {
+ m := prepMem(t) // adds two comments
+
+ req := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
+ res, err := m.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res))
+ assert.Equal(t, `some text, link`, res[0].Text)
+ assert.Equal(t, "user1", res[0].User.ID)
+ t.Log(res[0].ID)
+
+ _, err = m.Create(store.Comment{ID: res[0].ID, Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}})
+ require.NotNil(t, err)
+ assert.True(t, strings.Contains(err.Error(), "dup key"), err.Error())
+
+ id, err := m.Create(store.Comment{ID: "id-3", Locator: store.Locator{URL: "https://radio-t2.com", SiteID: "radio-t2"}})
+ require.NoError(t, err)
+ assert.Equal(t, "id-3", id)
+ req = engine.FindRequest{Locator: store.Locator{URL: "https://radio-t2.com", SiteID: "radio-t2"}, Sort: "time"}
+ res, err = m.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 1, len(res))
+}
+
+func TestMem_CreateFailedReadOnly(t *testing.T) {
+ b := prepMem(t)
+ comment := store.Comment{
+ ID: "id-ro",
+ Text: `some text, link`,
+ Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
+ Locator: store.Locator{URL: "https://radio-t.com/ro", SiteID: "radio-t"},
+ User: store.User{ID: "user1", Name: "user name"},
+ }
+
+ flagReq := engine.FlagRequest{Locator: comment.Locator, Flag: engine.ReadOnly, Update: engine.FlagTrue}
+ v, err := b.Flag(flagReq)
+ require.NoError(t, err)
+ assert.Equal(t, true, v)
+
+ _, err = b.Create(comment)
+ assert.NotNil(t, err)
+ assert.Equal(t, "post https://radio-t.com/ro is read-only", err.Error())
+
+ flagReq = engine.FlagRequest{Locator: comment.Locator, Flag: engine.ReadOnly, Update: engine.FlagFalse}
+ v, err = b.Flag(flagReq)
+ require.NoError(t, err)
+ assert.Equal(t, false, v)
+
+ _, err = b.Create(comment)
+ assert.NoError(t, err)
+}
+
+func TestMem_Get(t *testing.T) {
+ b := prepMem(t)
+ req := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
+ res, err := b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res), "2 records initially")
+
+ comment, err := b.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[1].ID))
+ assert.NoError(t, err)
+ assert.Equal(t, "some text2", comment.Text)
+
+ _, err = b.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "1234567"))
+ assert.EqualError(t, err, `not found`)
+
+ _, err = b.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "bad"}, res[1].ID))
+ assert.EqualError(t, err, `not found`)
+}
+
+func TestMem_Update(t *testing.T) {
+ b := prepMem(t)
+ req := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
+ res, err := b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res), "2 records initially")
+
+ comment := res[0]
+ comment.Text = "abc 123"
+ comment.Score = 100
+ err = b.Update(comment)
+ assert.NoError(t, err)
+
+ comment, err = b.Get(getReq(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID))
+ assert.NoError(t, err)
+ assert.Equal(t, "abc 123", comment.Text)
+ assert.Equal(t, res[0].ID, comment.ID)
+ assert.Equal(t, 100, comment.Score)
+
+ comment.Locator.SiteID = "bad"
+ err = b.Update(comment)
+ assert.EqualError(t, err, `not found`)
+
+ comment.Locator.SiteID = "https://radio-t.com"
+ comment.Locator.URL = "https://radio-t.com-bad"
+ err = b.Update(comment)
+ assert.EqualError(t, err, `not found`)
+}
+
+func TestMem_FindLast(t *testing.T) {
+ b := prepMem(t)
+ req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time"}
+ res, err := b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res))
+ assert.Equal(t, "some text2", res[0].Text)
+
+ req.Limit = 1
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 1, len(res))
+ assert.Equal(t, "some text2", res[0].Text)
+
+ req.Locator.SiteID = "bad"
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(res))
+}
+
+func TestMem_FindLastSince(t *testing.T) {
+ b := prepMem(t)
+ ts := time.Date(2017, 12, 20, 15, 18, 21, 0, time.Local)
+ req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", Since: ts}
+ res, err := b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res))
+ assert.Equal(t, "some text2", res[0].Text)
+
+ req.Since = time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local)
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 1, len(res))
+ assert.Equal(t, "some text2", res[0].Text)
+
+ req.Since = time.Date(2017, 12, 20, 16, 18, 22, 0, time.Local)
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(res))
+}
+
+func TestMem_FindForUser(t *testing.T) {
+ b := prepMem(t)
+ req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "user1", Limit: 5}
+ res, err := b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res))
+ assert.Equal(t, "some text2", res[0].Text, "sorted by -time")
+
+ req = engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "user1", Limit: 1}
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 1, len(res), "allow 1 comment")
+ assert.Equal(t, "some text2", res[0].Text, "sorted by -time")
+
+ req = engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "user1", Limit: 1, Skip: 1}
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 1, len(res), "allow 1 comment")
+ assert.Equal(t, `some text, link`, res[0].Text, "second comment")
+
+ req = engine.FindRequest{Locator: store.Locator{SiteID: "bad"}, Sort: "-time", UserID: "user1", Limit: 1, Skip: 1}
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(res), "no comments")
+
+ req = engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "userZ", Limit: 1, Skip: 1}
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(res), "no comments")
+}
+
+func TestMem_FindForUserPagination(t *testing.T) {
+ b := NewMemEngine()
+
+ c := store.Comment{
+ Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
+ User: store.User{ID: "user1", Name: "user name"},
+ }
+
+ // write 200 comments
+ for i := 0; i < 200; i++ {
+ c.ID = fmt.Sprintf("idd-%d", i)
+ c.Text = fmt.Sprintf("text #%d", i)
+ c.Timestamp = time.Date(2017, 12, 20, 15, 18, i, 0, time.Local)
+ _, err := b.Create(c)
+ require.Nil(t, err)
+ }
+
+ // get all comments
+ req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", UserID: "user1"}
+ res, err := b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 200, len(res))
+ assert.Equal(t, "idd-199", res[0].ID)
+
+ // seek 0, 5 comments
+ req.Limit = 5
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 5, len(res))
+ assert.Equal(t, "idd-199", res[0].ID)
+ assert.Equal(t, "idd-195", res[4].ID)
+
+ // seek 10, 3 comments
+ req.Skip, req.Limit = 10, 3
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 3, len(res))
+ assert.Equal(t, "idd-189", res[0].ID)
+ assert.Equal(t, "idd-187", res[2].ID)
+
+ // seek 195, ask 10 comments
+ req.Skip, req.Limit = 195, 10
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ require.Equal(t, 5, len(res))
+ assert.Equal(t, "idd-4", res[0].ID)
+ assert.Equal(t, "idd-0", res[4].ID)
+
+ // seek 255, ask 10 comments
+ req.Skip, req.Limit = 255, 10
+ res, err = b.Find(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(res))
+}
+
+func TestMem_CountPost(t *testing.T) {
+ b := prepMem(t)
+ req := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}}
+ c, err := b.Count(req)
+ assert.NoError(t, err)
+ require.Equal(t, 2, c)
+
+ req = engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com-xxx", SiteID: "radio-t"}}
+ c, err = b.Count(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, c)
+
+ req = engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "bad"}}
+ c, err = b.Count(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, c)
+}
+
+func TestMem_CountUser(t *testing.T) {
+ b := prepMem(t)
+ req := engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"}
+ c, err := b.Count(req)
+ assert.NoError(t, err)
+ require.Equal(t, 2, c)
+
+ req = engine.FindRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1"}
+ c, err = b.Count(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, c)
+
+ req = engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "userZ"}
+ c, err = b.Count(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, c)
+}
+
+func TestMem_InfoPost(t *testing.T) {
+ b := prepMem(t)
+ ts := func(min int) time.Time { return time.Date(2017, 12, 20, 15, 18, min, 0, time.Local).In(time.UTC) }
+
+ // add one more for https://radio-t.com/2
+ comment := store.Comment{
+ ID: "12345",
+ Text: `some text, link`,
+ Timestamp: time.Date(2017, 12, 20, 15, 18, 24, 0, time.Local),
+ Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
+ User: store.User{ID: "user1", Name: "user name"},
+ }
+ _, err := b.Create(comment)
+ assert.NoError(t, err)
+
+ req := engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, ReadOnlyAge: 0}
+ r, err := b.Info(req)
+ require.NoError(t, err)
+ assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(24), LastTS: ts(24)}}, r)
+
+ req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, ReadOnlyAge: 10}
+ r, err = b.Info(req)
+ require.NoError(t, err)
+ assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1,
+ FirstTS: ts(24), LastTS: ts(24), ReadOnly: true}}, r)
+
+ req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, ReadOnlyAge: 0}
+ r, err = b.Info(req)
+ require.NoError(t, err)
+ assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, r)
+
+ req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com/error", SiteID: "radio-t"}, ReadOnlyAge: 0}
+ _, err = b.Info(req)
+ require.NotNil(t, err)
+
+ req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t-error"}, ReadOnlyAge: 0}
+ _, err = b.Info(req)
+ require.NotNil(t, err)
+
+ fr := engine.FlagRequest{Flag: engine.ReadOnly,
+ Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, Update: engine.FlagTrue}
+ _, err = b.Flag(fr)
+ require.NoError(t, err)
+ req = engine.InfoRequest{Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"}, ReadOnlyAge: 0}
+ r, err = b.Info(req)
+ require.NoError(t, err)
+ assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(24), LastTS: ts(24),
+ ReadOnly: true}}, r)
+}
+
+func TestMem_InfoList(t *testing.T) {
+ b := prepMem(t)
+ // add one more for https://radio-t.com/2
+ comment := store.Comment{
+ ID: "12345",
+ Text: `some text, link`,
+ Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
+ Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
+ User: store.User{ID: "user1", Name: "user name"},
+ }
+ _, err := b.Create(comment)
+ assert.NoError(t, err)
+
+ ts := func(sec int) time.Time { return time.Date(2017, 12, 20, 15, 18, sec, 0, time.Local).In(time.UTC) }
+
+ req := engine.InfoRequest{Locator: store.Locator{SiteID: "radio-t"}}
+ res, err := b.Info(req)
+ assert.NoError(t, err)
+ assert.EqualValues(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)},
+ {URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, res)
+
+ req = engine.InfoRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: -1, Skip: -1}
+ res, err = b.Info(req)
+ assert.NoError(t, err)
+ assert.EqualValues(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)},
+ {URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, res)
+
+ req = engine.InfoRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: 1}
+ res, err = b.Info(req)
+ assert.NoError(t, err)
+ assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/2", Count: 1, FirstTS: ts(22), LastTS: ts(22)}}, res)
+
+ req = engine.InfoRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: 1, Skip: 1}
+ res, err = b.Info(req)
+ assert.NoError(t, err)
+ assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com", Count: 2, FirstTS: ts(22), LastTS: ts(23)}}, res)
+
+ req = engine.InfoRequest{Locator: store.Locator{SiteID: "bad"}, Limit: 1, Skip: 1}
+ res, err = b.Info(req)
+ assert.NoError(t, err)
+ assert.Equal(t, []store.PostInfo{}, res)
+}
+
+func TestMem_FlagBlockedUser(t *testing.T) {
+
+ b := prepMem(t)
+ req := engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"}
+ val, err := b.Flag(req)
+ assert.NoError(t, err)
+ assert.False(t, val, "nothing blocked yet")
+
+ req = engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
+ Update: engine.FlagTrue}
+ _, err = b.Flag(req)
+ assert.NoError(t, err)
+ val, err = b.Flag(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"})
+ assert.NoError(t, err)
+ assert.True(t, val, "user1 blocked")
+
+ req = engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
+ Update: engine.FlagTrue}
+ _, err = b.Flag(req)
+ assert.NoError(t, err)
+ val, err = b.Flag(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"})
+ assert.NoError(t, err)
+ assert.True(t, val, "user1 still blocked")
+
+ req = engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
+ Update: engine.FlagFalse}
+ _, err = b.Flag(req)
+ assert.NoError(t, err)
+ val, err = b.Flag(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1"})
+ assert.NoError(t, err)
+ assert.False(t, val, "user1 unblocked")
+}
+
+func TestMem_FlagReadOnlyPost(t *testing.T) {
+
+ b := prepMem(t)
+ req := engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly}
+ val, err := b.Flag(req)
+ assert.NoError(t, err)
+ assert.False(t, val, "nothing ro")
+
+ req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly,
+ Update: engine.FlagTrue}
+ val, err = b.Flag(req)
+ assert.NoError(t, err)
+ req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly}
+ val, err = b.Flag(req)
+ assert.NoError(t, err)
+ assert.True(t, val, "url-1 ro")
+
+ req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-2"}, Flag: engine.ReadOnly}
+ val, err = b.Flag(req)
+ assert.NoError(t, err)
+ assert.False(t, val, "url-2 still writable")
+
+ req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly,
+ Update: engine.FlagFalse}
+ _, err = b.Flag(req)
+ assert.NoError(t, err)
+ req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly}
+ val, err = b.Flag(req)
+ assert.NoError(t, err)
+ assert.False(t, val, "url-1 writable")
+}
+
+func TestMem_FlagVerified(t *testing.T) {
+
+ b := prepMem(t)
+ isVerified := func(site, user string) bool {
+ req := engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: site}, UserID: user}
+ v, err := b.Flag(req)
+ require.NoError(t, err)
+ return v
+ }
+
+ setVerified := func(site, user string, status engine.FlagStatus) error {
+ req := engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: site}, UserID: user, Update: status}
+ _, err := b.Flag(req)
+ return err
+ }
+
+ assert.False(t, isVerified("radio-t", "u1"), "nothing verified")
+
+ assert.NoError(t, setVerified("radio-t", "u1", engine.FlagTrue))
+ assert.True(t, isVerified("radio-t", "u1"), "u1 verified")
+
+ assert.False(t, isVerified("radio-t", "u2"), "u2 still not verified")
+ assert.NoError(t, setVerified("radio-t", "u1", engine.FlagFalse))
+ assert.False(t, isVerified("radio-t", "u1"), "u1 not verified anymore")
+
+ assert.NoError(t, setVerified("bad", "u1", engine.FlagTrue))
+ assert.NoError(t, setVerified("radio-t", "u1xyz", engine.FlagFalse))
+
+ assert.False(t, isVerified("radio-t-bad", "u1"), "nothing verified on wrong site")
+
+ assert.NoError(t, setVerified("radio-t", "u1", engine.FlagTrue))
+ assert.NoError(t, setVerified("radio-t", "u2", engine.FlagTrue))
+ assert.NoError(t, setVerified("radio-t", "u3", engine.FlagFalse))
+}
+
+func TestMem_FlagListVerified(t *testing.T) {
+
+ b := prepMem(t)
+ toIDs := func(inp []interface{}) (res []string) {
+ res = make([]string, len(inp))
+ for i, v := range inp {
+ vv, ok := v.(string)
+ require.True(t, ok)
+ res[i] = vv
+ }
+ return res
+ }
+
+ setVerified := func(site, user string, status engine.FlagStatus) error {
+ req := engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: site}, UserID: user, Update: status}
+ _, err := b.Flag(req)
+ return err
+ }
+
+ ids, err := b.ListFlags(engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: "radio-t"}})
+ assert.NoError(t, err)
+ assert.Equal(t, []string{}, toIDs(ids), "verified list empty")
+
+ assert.NoError(t, setVerified("radio-t", "u1", engine.FlagTrue))
+ assert.NoError(t, setVerified("radio-t", "u2", engine.FlagTrue))
+ ids, err = b.ListFlags(engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: "radio-t"}})
+ assert.NoError(t, err)
+ assert.EqualValues(t, []string{"u1", "u2"}, toIDs(ids), "verified 2 ids")
+
+ ids, err = b.ListFlags(engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: "radio-t-bad"}})
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(ids))
+}
+
+func TestMem_FlagListBlocked(t *testing.T) {
+
+ b := prepMem(t)
+ setBlocked := func(site, user string, status engine.FlagStatus, ttl time.Duration) error {
+ req := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status,
+ TTL: ttl}
+ _, err := b.Flag(req)
+ return err
+ }
+
+ toBlocked := func(inp []interface{}) (res []store.BlockedUser) {
+ res = make([]store.BlockedUser, len(inp))
+ for i, v := range inp {
+ vv, ok := v.(store.BlockedUser)
+ require.True(t, ok)
+ res[i] = vv
+ }
+ return res
+ }
+ assert.NoError(t, setBlocked("radio-t", "user1", engine.FlagTrue, 0))
+ assert.NoError(t, setBlocked("radio-t", "user2", engine.FlagTrue, 50*time.Millisecond))
+ assert.NoError(t, setBlocked("radio-t", "user3", engine.FlagFalse, 0))
+
+ vv, err := b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
+ assert.NoError(t, err)
+
+ blockedList := toBlocked(vv)
+ require.Equal(t, 2, len(blockedList), b.metaUsers)
+ assert.Equal(t, "user1", blockedList[0].ID)
+ assert.Equal(t, "user2", blockedList[1].ID)
+ t.Logf("%+v", blockedList)
+
+ // check block expiration
+ time.Sleep(50 * time.Millisecond)
+ vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
+ assert.NoError(t, err)
+ blockedList = toBlocked(vv)
+ require.Equal(t, 1, len(blockedList))
+ assert.Equal(t, "user1", blockedList[0].ID)
+
+ vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "bad"}})
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(vv))
+}
+
+func TestMem_DeleteComment(t *testing.T) {
+
+ b := prepMem(t)
+ reqReq := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
+ res, err := b.Find(reqReq)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res), "initially 2 comments")
+
+ count, err := b.Count(reqReq)
+ require.NoError(t, err)
+ require.Equal(t, 2, count, "count=2 initially")
+
+ delReq := engine.DeleteRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
+ CommentID: res[0].ID, DeleteMode: store.SoftDelete}
+
+ err = b.Delete(delReq)
+ assert.NoError(t, err)
+
+ res, err = b.Find(reqReq)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res))
+ assert.Equal(t, "", res[0].Text)
+ assert.True(t, res[0].Deleted, "marked deleted")
+ assert.Equal(t, store.User{Name: "user name", ID: "user1", Picture: "", Admin: false, Blocked: false, IP: ""}, res[0].User)
+
+ assert.Equal(t, "some text2", res[1].Text)
+ assert.False(t, res[1].Deleted)
+
+ comments, err := b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: 10})
+ assert.NoError(t, err)
+ assert.Equal(t, 1, len(comments), "1 in last, 1 removed")
+
+ count, err = b.Count(reqReq)
+ require.NoError(t, err)
+ assert.Equal(t, 1, count)
+
+ delReq.CommentID = "123456"
+ err = b.Delete(delReq)
+ assert.NotNil(t, err)
+
+ delReq.Locator.SiteID = "bad"
+ delReq.CommentID = res[0].ID
+ err = b.Delete(delReq)
+ assert.EqualError(t, err, `not found`)
+
+ delReq.Locator = store.Locator{URL: "https://radio-t.com/bad", SiteID: "radio-t"}
+ err = b.Delete(delReq)
+ assert.EqualError(t, err, `not found`)
+}
+
+func TestMem_DeleteHard(t *testing.T) {
+
+ b := prepMem(t)
+ reqReq := engine.FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "time"}
+ res, err := b.Find(reqReq)
+ assert.NoError(t, err)
+ assert.Equal(t, 2, len(res), "initially 2 comments")
+
+ delReq := engine.DeleteRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
+ CommentID: res[0].ID, DeleteMode: store.HardDelete}
+ err = b.Delete(delReq)
+ assert.NoError(t, err)
+
+ res, err = b.Find(reqReq)
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(res))
+ assert.Equal(t, "", res[0].Text)
+ assert.True(t, res[0].Deleted, "marked deleted")
+ assert.Equal(t, store.User{Name: "deleted", ID: "deleted", Picture: "", Admin: false, Blocked: false, IP: ""}, res[0].User)
+}
+
+func TestMem_DeleteAll(t *testing.T) {
+ b := prepMem(t)
+ delReq := engine.DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}}
+ err := b.Delete(delReq)
+ assert.NoError(t, err)
+
+ comments, err := b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Limit: 10})
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(comments), "nothing left")
+}
+
+func TestBoltAdmin_DeleteUserHard(t *testing.T) {
+ b := prepMem(t)
+ err := b.Delete(engine.DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
+ DeleteMode: store.HardDelete})
+ require.NoError(t, err)
+
+ comments, err := b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"},
+ Sort: "time"})
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(comments), "2 comments with deleted info")
+ assert.Equal(t, store.User{Name: "deleted", ID: "deleted", Picture: "", Admin: false, Blocked: false, IP: ""}, comments[0].User)
+ assert.Equal(t, store.User{Name: "deleted", ID: "deleted", Picture: "", Admin: false, Blocked: false, IP: ""}, comments[1].User)
+
+ c, err := b.Count(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"}})
+ assert.NoError(t, err)
+ assert.Equal(t, 0, c, "0 count")
+
+ _, err = b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1", Limit: 5})
+ assert.NoError(t, err, "no comments for user user1 in store")
+
+ comments, err = b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "time"})
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(comments), "nothing left")
+}
+
+func TestBoltAdmin_DeleteUserSoft(t *testing.T) {
+
+ b := prepMem(t)
+ err := b.Delete(engine.DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1",
+ DeleteMode: store.SoftDelete})
+ require.NoError(t, err)
+
+ comments, err := b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"},
+ Sort: "time"})
+ assert.NoError(t, err)
+ require.Equal(t, 2, len(comments), "2 comments with deleted info")
+ assert.Equal(t, store.User{Name: "user name", ID: "user1", Picture: "", Admin: false, Blocked: false, IP: ""}, comments[0].User)
+ assert.Equal(t, store.User{Name: "user name", ID: "user1", Picture: "", Admin: false, Blocked: false, IP: ""}, comments[1].User)
+
+ c, err := b.Count(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"}})
+ assert.NoError(t, err)
+ assert.Equal(t, 0, c, "0 count")
+
+ comments, err = b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "user1", Limit: 5})
+ assert.NoError(t, err, "no comments for user user1 in store")
+ require.Equal(t, 2, len(comments), "2 comments with deleted info")
+ assert.True(t, comments[0].Deleted)
+ assert.True(t, comments[1].Deleted)
+ assert.Equal(t, "", comments[0].Text)
+ assert.Equal(t, "", comments[1].Text)
+
+ comments, err = b.Find(engine.FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "time"})
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(comments), "nothing left")
+}
+
+func prepMem(t *testing.T) *MemEngine {
+
+ m := NewMemEngine()
+
+ comment := store.Comment{
+ ID: "id-1",
+ Text: `some text, link`,
+ Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
+ Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
+ User: store.User{ID: "user1", Name: "user name"},
+ }
+ _, err := m.Create(comment)
+ require.NoError(t, err)
+
+ comment = store.Comment{
+ ID: "id-2",
+ Text: "some text2",
+ Timestamp: time.Date(2017, 12, 20, 15, 18, 23, 0, time.Local),
+ Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
+ User: store.User{ID: "user1", Name: "user name"},
+ }
+ _, err = m.Create(comment)
+ require.NoError(t, err)
+ return m
+}
+
+func getReq(locator store.Locator, commentID string) engine.GetRequest {
+ return engine.GetRequest{
+ Locator: locator,
+ CommentID: commentID,
+ }
+}
diff --git a/backend/_example/rpc/server/store.go b/backend/_example/rpc/server/store.go
new file mode 100644
index 00000000..30f029a6
--- /dev/null
+++ b/backend/_example/rpc/server/store.go
@@ -0,0 +1,230 @@
+package server
+
+import (
+ "encoding/json"
+
+ "github.com/umputun/remark/backend/app/rpc"
+ "github.com/umputun/remark/backend/app/store"
+ "github.com/umputun/remark/backend/app/store/admin"
+ "github.com/umputun/remark/backend/app/store/engine"
+)
+
+// Store handler wraps both engine and remote server and implements all handlers
+// Note: this file can be used as-is in any custom rpc plugin
+type Store struct {
+ *rpc.Server
+ eng engine.Interface
+ adm admin.Store
+}
+
+// NewStore makes Store instance and register handlers
+func NewStore(e engine.Interface, a admin.Store, r *rpc.Server) *Store {
+ res := &Store{eng: e, adm: a, Server: r}
+ res.addHandlers()
+ return res
+}
+
+
+func (s *Store) addHandlers() {
+ // data store handlers
+ s.Group("store", rpc.HandlersGroup{
+ "create": s.createHndl,
+ "find": s.findHndl,
+ "get": s.getHndl,
+ "update": s.updateHndl,
+ "count": s.countHndl,
+ "info": s.infoHndl,
+ "flag": s.flagHndl,
+ "list_flags": s.listFlagsHndl,
+ "delete": s.deleteHndl,
+ "close": s.closeHndl,
+ })
+
+ // admin store handlers
+ s.Group("admin", rpc.HandlersGroup{
+ "key": s.admKeyHndl,
+ "admins": s.admAdminsHndl,
+ "email": s.admEmailHndl,
+ })
+}
+
+func (s *Store) createHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ comment := store.Comment{}
+ if err := json.Unmarshal(params, &comment); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ commentID, err := s.eng.Create(comment)
+ if rr, err = s.EncodeResponse(id, commentID, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+
+ }
+ return rr
+}
+
+// Find comments
+func (s *Store) findHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ req := engine.FindRequest{}
+ if err := json.Unmarshal(params, &req); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ resp, err := s.eng.Find(req)
+ if rr, err = s.EncodeResponse(id, resp, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// Get comment
+func (s *Store) getHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ req := engine.GetRequest{}
+ if err := json.Unmarshal(params, &req); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ comment, err := s.eng.Get(req)
+ if rr, err = s.EncodeResponse(id, comment, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// Update comment
+func (s *Store) updateHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ comment := store.Comment{}
+ if err := json.Unmarshal(params, &comment); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ err := s.eng.Update(comment)
+ if rr, err = s.EncodeResponse(id, nil, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// counts for site and users
+func (s *Store) countHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ req := engine.FindRequest{}
+ if err := json.Unmarshal(params, &req); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ count, err := s.eng.Count(req)
+ if rr, err = s.EncodeResponse(id, count, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// info get post meta info
+func (s *Store) infoHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ req := engine.InfoRequest{}
+ if err := json.Unmarshal(params, &req); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ info, err := s.eng.Info(req)
+ if rr, err = s.EncodeResponse(id, info, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// flagHndl get and sets flag value
+func (s *Store) flagHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ req := engine.FlagRequest{}
+ if err := json.Unmarshal(params, &req); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ status, err := s.eng.Flag(req)
+ if rr, err = s.EncodeResponse(id, status, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// listFlagsHndl list flags for given request
+func (s *Store) listFlagsHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ req := engine.FlagRequest{}
+ if err := json.Unmarshal(params, &req); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ flags, err := s.eng.ListFlags(req)
+ if rr, err = s.EncodeResponse(id, flags, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// deleteHndl remove comment(s)
+func (s *Store) deleteHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ req := engine.DeleteRequest{}
+ if err := json.Unmarshal(params, &req); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ err := s.eng.Delete(req)
+ if rr, err = s.EncodeResponse(id, nil, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// close store
+func (s *Store) closeHndl(id uint64, _ json.RawMessage) (rr rpc.Response) {
+ if err := s.eng.Close(); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rpc.Response{}
+}
+
+// get admin key
+func (s *Store) admKeyHndl(id uint64, _ json.RawMessage) (rr rpc.Response) {
+ key, err := s.adm.Key()
+ if err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ if rr, err = s.EncodeResponse(id, key, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// get admins list
+func (s *Store) admAdminsHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ args := []interface{}{}
+ if err := json.Unmarshal(params, &args); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ siteID, ok := args[0].(string)
+ if !ok {
+ return rpc.Response{Error: "incompatible argument"}
+ }
+
+ admins, err := s.adm.Admins(siteID)
+ if err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+
+ if rr, err = s.EncodeResponse(id, admins, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}
+
+// get admin email
+func (s *Store) admEmailHndl(id uint64, params json.RawMessage) (rr rpc.Response) {
+ args := []interface{}{}
+ if err := json.Unmarshal(params, &args); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ siteID, ok := args[0].(string)
+ if !ok {
+ return rpc.Response{Error: "incompatible argument"}
+ }
+
+ email, err := s.adm.Email(siteID)
+ if err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+
+ if rr, err = s.EncodeResponse(id, email, err); err != nil {
+ return rpc.Response{Error: err.Error()}
+ }
+ return rr
+}