Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
119
server/boards/app/app.go
Обычный файл
119
server/boards/app/app.go
Обычный файл
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/metrics"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/store"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/webhook"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/ws"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
blockChangeNotifierQueueSize = 1000
|
||||
blockChangeNotifierPoolSize = 10
|
||||
blockChangeNotifierShutdownTimeout = time.Second * 10
|
||||
)
|
||||
|
||||
type servicesAPI interface {
|
||||
GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error)
|
||||
}
|
||||
|
||||
type ReadCloseSeeker = filestore.ReadCloseSeeker
|
||||
|
||||
type fileBackend interface {
|
||||
Reader(path string) (ReadCloseSeeker, error)
|
||||
FileExists(path string) (bool, error)
|
||||
CopyFile(oldPath, newPath string) error
|
||||
MoveFile(oldPath, newPath string) error
|
||||
WriteFile(fr io.Reader, path string) (int64, error)
|
||||
RemoveFile(path string) error
|
||||
}
|
||||
|
||||
type Services struct {
|
||||
Auth *auth.Auth
|
||||
Store store.Store
|
||||
FilesBackend fileBackend
|
||||
Webhook *webhook.Client
|
||||
Metrics *metrics.Metrics
|
||||
Notifications *notify.Service
|
||||
Logger mlog.LoggerIFace
|
||||
Permissions permissions.PermissionsService
|
||||
SkipTemplateInit bool
|
||||
ServicesAPI servicesAPI
|
||||
}
|
||||
|
||||
type App struct {
|
||||
config *config.Configuration
|
||||
store store.Store
|
||||
auth *auth.Auth
|
||||
wsAdapter ws.Adapter
|
||||
filesBackend fileBackend
|
||||
webhook *webhook.Client
|
||||
metrics *metrics.Metrics
|
||||
notifications *notify.Service
|
||||
logger mlog.LoggerIFace
|
||||
permissions permissions.PermissionsService
|
||||
blockChangeNotifier *utils.CallbackQueue
|
||||
servicesAPI servicesAPI
|
||||
|
||||
cardLimitMux sync.RWMutex
|
||||
cardLimit int
|
||||
}
|
||||
|
||||
func (a *App) SetConfig(config *config.Configuration) {
|
||||
a.config = config
|
||||
}
|
||||
|
||||
func (a *App) GetConfig() *config.Configuration {
|
||||
return a.config
|
||||
}
|
||||
|
||||
func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App {
|
||||
app := &App{
|
||||
config: config,
|
||||
store: services.Store,
|
||||
auth: services.Auth,
|
||||
wsAdapter: wsAdapter,
|
||||
filesBackend: services.FilesBackend,
|
||||
webhook: services.Webhook,
|
||||
metrics: services.Metrics,
|
||||
notifications: services.Notifications,
|
||||
logger: services.Logger,
|
||||
permissions: services.Permissions,
|
||||
blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger),
|
||||
servicesAPI: services.ServicesAPI,
|
||||
}
|
||||
app.initialize(services.SkipTemplateInit)
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *App) CardLimit() int {
|
||||
a.cardLimitMux.RLock()
|
||||
defer a.cardLimitMux.RUnlock()
|
||||
return a.cardLimit
|
||||
}
|
||||
|
||||
func (a *App) SetCardLimit(cardLimit int) {
|
||||
a.cardLimitMux.Lock()
|
||||
defer a.cardLimitMux.Unlock()
|
||||
a.cardLimit = cardLimit
|
||||
}
|
||||
|
||||
func (a *App) GetLicense() *mm_model.License {
|
||||
return a.store.GetLicense()
|
||||
}
|
||||
26
server/boards/app/app_test.go
Обычный файл
26
server/boards/app/app_test.go
Обычный файл
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
|
||||
)
|
||||
|
||||
func TestSetConfig(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("Test Update Config", func(t *testing.T) {
|
||||
require.False(t, th.App.config.EnablePublicSharedBoards)
|
||||
newConfiguration := config.Configuration{}
|
||||
newConfiguration.EnablePublicSharedBoards = true
|
||||
th.App.SetConfig(&newConfiguration)
|
||||
|
||||
require.True(t, th.App.config.EnablePublicSharedBoards)
|
||||
})
|
||||
}
|
||||
234
server/boards/app/auth.go
Обычный файл
234
server/boards/app/auth.go
Обычный файл
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
DaysPerMonth = 30
|
||||
DaysPerWeek = 7
|
||||
HoursPerDay = 24
|
||||
MinutesPerHour = 60
|
||||
SecondsPerMinute = 60
|
||||
)
|
||||
|
||||
// GetSession Get a user active session and refresh the session if is needed.
|
||||
func (a *App) GetSession(token string) (*model.Session, error) {
|
||||
return a.auth.GetSession(token)
|
||||
}
|
||||
|
||||
// IsValidReadToken validates the read token for a block.
|
||||
func (a *App) IsValidReadToken(boardID string, readToken string) (bool, error) {
|
||||
return a.auth.IsValidReadToken(boardID, readToken)
|
||||
}
|
||||
|
||||
// GetRegisteredUserCount returns the number of registered users.
|
||||
func (a *App) GetRegisteredUserCount() (int, error) {
|
||||
return a.store.GetRegisteredUserCount()
|
||||
}
|
||||
|
||||
// GetDailyActiveUsers returns the number of daily active users.
|
||||
func (a *App) GetDailyActiveUsers() (int, error) {
|
||||
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay)
|
||||
return a.store.GetActiveUserCount(secondsAgo)
|
||||
}
|
||||
|
||||
// GetWeeklyActiveUsers returns the number of weekly active users.
|
||||
func (a *App) GetWeeklyActiveUsers() (int, error) {
|
||||
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay * DaysPerWeek)
|
||||
return a.store.GetActiveUserCount(secondsAgo)
|
||||
}
|
||||
|
||||
// GetMonthlyActiveUsers returns the number of monthly active users.
|
||||
func (a *App) GetMonthlyActiveUsers() (int, error) {
|
||||
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay * DaysPerMonth)
|
||||
return a.store.GetActiveUserCount(secondsAgo)
|
||||
}
|
||||
|
||||
// GetUser gets an existing active user by id.
|
||||
func (a *App) GetUser(id string) (*model.User, error) {
|
||||
if len(id) < 1 {
|
||||
return nil, errors.New("no user ID")
|
||||
}
|
||||
|
||||
user, err := a.store.GetUserByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to find user")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUsersList(userIDs []string) ([]*model.User, error) {
|
||||
if len(userIDs) == 0 {
|
||||
return nil, errors.New("No User IDs")
|
||||
}
|
||||
|
||||
users, err := a.store.GetUsersList(userIDs, a.config.ShowEmailAddress, a.config.ShowFullName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to find users")
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Login create a new user session if the authentication data is valid.
|
||||
func (a *App) Login(username, email, password, mfaToken string) (string, error) {
|
||||
var user *model.User
|
||||
if username != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByUsername(username)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
a.metrics.IncrementLoginFailCount(1)
|
||||
return "", errors.Wrap(err, "invalid username or password")
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil && email != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByEmail(email)
|
||||
if err != nil && model.IsErrNotFound(err) {
|
||||
a.metrics.IncrementLoginFailCount(1)
|
||||
return "", errors.Wrap(err, "invalid username or password")
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
a.metrics.IncrementLoginFailCount(1)
|
||||
return "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
if !auth.ComparePassword(user.Password, password) {
|
||||
a.metrics.IncrementLoginFailCount(1)
|
||||
a.logger.Debug("Invalid password for user", mlog.String("userID", user.ID))
|
||||
return "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
authService := user.AuthService
|
||||
if authService == "" {
|
||||
authService = "native"
|
||||
}
|
||||
|
||||
session := model.Session{
|
||||
ID: utils.NewID(utils.IDTypeSession),
|
||||
Token: utils.NewID(utils.IDTypeToken),
|
||||
UserID: user.ID,
|
||||
AuthService: authService,
|
||||
Props: map[string]interface{}{},
|
||||
}
|
||||
err := a.store.CreateSession(&session)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "unable to create session")
|
||||
}
|
||||
|
||||
a.metrics.IncrementLoginCount(1)
|
||||
|
||||
// TODO: MFA verification
|
||||
return session.Token, nil
|
||||
}
|
||||
|
||||
// Logout invalidates the user session.
|
||||
func (a *App) Logout(sessionID string) error {
|
||||
err := a.store.DeleteSession(sessionID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to delete the session")
|
||||
}
|
||||
|
||||
a.metrics.IncrementLogoutCount(1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterUser creates a new user if the provided data is valid.
|
||||
func (a *App) RegisterUser(username, email, password string) error {
|
||||
var user *model.User
|
||||
if username != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByUsername(username)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if user != nil {
|
||||
return errors.New("The username already exists")
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil && email != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByEmail(email)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if user != nil {
|
||||
return errors.New("The email already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move this into the config
|
||||
passwordSettings := auth.PasswordSettings{
|
||||
MinimumLength: 6,
|
||||
}
|
||||
|
||||
err := auth.IsPasswordValid(password, passwordSettings)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid password")
|
||||
}
|
||||
|
||||
_, err = a.store.CreateUser(&model.User{
|
||||
ID: utils.NewID(utils.IDTypeUser),
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: auth.HashPassword(password),
|
||||
MfaSecret: "",
|
||||
AuthService: a.config.AuthMode,
|
||||
AuthData: "",
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to create the new user")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateUserPassword(username, password string) error {
|
||||
err := a.store.UpdateUserPassword(username, auth.HashPassword(password))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ChangePassword(userID, oldPassword, newPassword string) error {
|
||||
var user *model.User
|
||||
if userID != "" {
|
||||
var err error
|
||||
user, err = a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "invalid username or password")
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
if !auth.ComparePassword(user.Password, oldPassword) {
|
||||
a.logger.Debug("Invalid password for user", mlog.String("userID", user.ID))
|
||||
return errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
err := a.store.UpdateUserPasswordByID(userID, auth.HashPassword(newPassword))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to update password")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
192
server/boards/app/auth_test.go
Обычный файл
192
server/boards/app/auth_test.go
Обычный файл
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
var mockUser = &model.User{
|
||||
ID: utils.NewID(utils.IDTypeUser),
|
||||
Username: "testUsername",
|
||||
Email: "testEmail",
|
||||
Password: auth.HashPassword("testPassword"),
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
userName string
|
||||
email string
|
||||
password string
|
||||
mfa string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing login information", "", "", "", "", true},
|
||||
{"fail, invalid username", "badUsername", "", "", "", true},
|
||||
{"fail, invalid email", "", "badEmail", "", "", true},
|
||||
{"fail, invalid password", "testUsername", "", "badPassword", "", true},
|
||||
{"success, using username", "testUsername", "", "testPassword", "", false},
|
||||
{"success, using email", "", "testEmail", "testPassword", "", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetUserByUsername("badUsername").Return(nil, errors.New("Bad Username"))
|
||||
th.Store.EXPECT().GetUserByEmail("badEmail").Return(nil, errors.New("Bad Email"))
|
||||
th.Store.EXPECT().GetUserByUsername("testUsername").Return(mockUser, nil).Times(2)
|
||||
th.Store.EXPECT().GetUserByEmail("testEmail").Return(mockUser, nil)
|
||||
th.Store.EXPECT().CreateSession(gomock.Any()).Return(nil).Times(2)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
token, err := th.App.Login(test.userName, test.email, test.password, test.mfa)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
id string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing id", "", true},
|
||||
{"fail, invalid id", "badID", true},
|
||||
{"success", "goodID", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetUserByID("badID").Return(nil, errors.New("Bad Id"))
|
||||
th.Store.EXPECT().GetUserByID("goodID").Return(mockUser, nil)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
token, err := th.App.GetUser(test.id)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUser(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
userName string
|
||||
email string
|
||||
password string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing login information", "", "", "", true},
|
||||
{"fail, username exists", "existingUsername", "", "", true},
|
||||
{"fail, email exists", "", "existingEmail", "", true},
|
||||
{"fail, invalid password", "newUsername", "", "test", true},
|
||||
{"success, using email", "", "newEmail", "testPassword", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetUserByUsername("existingUsername").Return(mockUser, nil)
|
||||
th.Store.EXPECT().GetUserByUsername("newUsername").Return(mockUser, errors.New("user not found"))
|
||||
th.Store.EXPECT().GetUserByEmail("existingEmail").Return(mockUser, nil)
|
||||
th.Store.EXPECT().GetUserByEmail("newEmail").Return(nil, model.NewErrNotFound("user"))
|
||||
th.Store.EXPECT().CreateUser(gomock.Any()).Return(nil, nil)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
err := th.App.RegisterUser(test.userName, test.email, test.password)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPassword(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
userName string
|
||||
password string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing login information", "", "", true},
|
||||
{"fail, invalid username", "badUsername", "", true},
|
||||
{"success, username", "testUsername", "testPassword", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().UpdateUserPassword("", gomock.Any()).Return(errors.New("user not found"))
|
||||
th.Store.EXPECT().UpdateUserPassword("badUsername", gomock.Any()).Return(errors.New("user not found"))
|
||||
th.Store.EXPECT().UpdateUserPassword("testUsername", gomock.Any()).Return(nil)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
err := th.App.UpdateUserPassword(test.userName, test.password)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePassword(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testcases := []struct {
|
||||
title string
|
||||
userName string
|
||||
oldPassword string
|
||||
password string
|
||||
isError bool
|
||||
}{
|
||||
{"fail, missing login information", "", "", "", true},
|
||||
{"fail, invalid userId", "badID", "", "", true},
|
||||
{"fail, invalid password", mockUser.ID, "wrongPassword", "newPassword", true},
|
||||
{"success, using username", mockUser.ID, "testPassword", "newPassword", false},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetUserByID("badID").Return(nil, errors.New("userID not found"))
|
||||
th.Store.EXPECT().GetUserByID(mockUser.ID).Return(mockUser, nil).Times(2)
|
||||
th.Store.EXPECT().UpdateUserPasswordByID(mockUser.ID, gomock.Any()).Return(nil)
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
err := th.App.ChangePassword(test.userName, test.oldPassword, test.password)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
588
server/boards/app/blocks.go
Обычный файл
588
server/boards/app/blocks.go
Обычный файл
@@ -0,0 +1,588 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards")
|
||||
|
||||
func (a *App) GetBlocks(boardID, parentID string, blockType string) ([]*model.Block, error) {
|
||||
if boardID == "" {
|
||||
return []*model.Block{}, nil
|
||||
}
|
||||
|
||||
if blockType != "" && parentID != "" {
|
||||
return a.store.GetBlocksWithParentAndType(boardID, parentID, blockType)
|
||||
}
|
||||
|
||||
if blockType != "" {
|
||||
return a.store.GetBlocksWithType(boardID, blockType)
|
||||
}
|
||||
|
||||
return a.store.GetBlocksWithParent(boardID, parentID)
|
||||
}
|
||||
|
||||
func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTemplate bool) ([]*model.Block, error) {
|
||||
board, err := a.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if board == nil {
|
||||
return nil, fmt.Errorf("cannot fetch board %s for DuplicateBlock: %w", boardID, err)
|
||||
}
|
||||
|
||||
blocks, err := a.store.DuplicateBlock(boardID, blockID, userID, asTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
for _, block := range blocks {
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed duplicating a block",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return blocks, err
|
||||
}
|
||||
|
||||
func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedByID string) (*model.Block, error) {
|
||||
return a.PatchBlockAndNotify(blockID, blockPatch, modifiedByID, false)
|
||||
}
|
||||
|
||||
func (a *App) PatchBlockAndNotify(blockID string, blockPatch *model.BlockPatch, modifiedByID string, disableNotify bool) (*model.Block, error) {
|
||||
oldBlock, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, lErr := a.ContainsLimitedBlocks([]*model.Block{oldBlock})
|
||||
if lErr != nil {
|
||||
return nil, lErr
|
||||
}
|
||||
if containsLimitedBlocks {
|
||||
return nil, model.ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(oldBlock.BoardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = a.store.PatchBlock(blockID, blockPatch, modifiedByID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.metrics.IncrementBlocksPatched(1)
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
// broadcast on websocket
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
|
||||
// broadcast on webhooks
|
||||
a.webhook.NotifyUpdate(block)
|
||||
|
||||
// send notifications
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Update, block, oldBlock, modifiedByID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string) error {
|
||||
return a.PatchBlocksAndNotify(teamID, blockPatches, modifiedByID, false)
|
||||
}
|
||||
|
||||
func (a *App) PatchBlocksAndNotify(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string, disableNotify bool) error {
|
||||
oldBlocks, err := a.store.GetBlocksByIDs(blockPatches.BlockIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, err := a.ContainsLimitedBlocks(oldBlocks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if containsLimitedBlocks {
|
||||
return model.ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.store.PatchBlocks(blockPatches, modifiedByID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.metrics.IncrementBlocksPatched(len(oldBlocks))
|
||||
for i, blockID := range blockPatches.BlockIDs {
|
||||
newBlock, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, newBlock)
|
||||
a.webhook.NotifyUpdate(newBlock)
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Update, newBlock, oldBlocks[i], modifiedByID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) InsertBlock(block *model.Block, modifiedByID string) error {
|
||||
return a.InsertBlockAndNotify(block, modifiedByID, false)
|
||||
}
|
||||
|
||||
func (a *App) InsertBlockAndNotify(block *model.Block, modifiedByID string, disableNotify bool) error {
|
||||
board, bErr := a.store.GetBoard(block.BoardID)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
|
||||
err := a.store.InsertBlock(block, modifiedByID)
|
||||
if err == nil {
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
a.webhook.NotifyUpdate(block)
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Add, block, nil, modifiedByID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after inserting a block",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) isWithinViewsLimit(boardID string, block *model.Block) (bool, error) {
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
|
||||
/*
|
||||
limits, err := a.GetBoardsCloudLimits()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if limits.Views == model.LimitUnlimited {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
views, err := a.store.GetBlocksWithParentAndType(boardID, block.ParentID, model.TypeView)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// < rather than <= because we'll be creating new view if this
|
||||
// check passes. When that view is created, the limit will be reached.
|
||||
// That's why we need to check for if existing + the being-created
|
||||
// view doesn't exceed the limit.
|
||||
return len(views) < limits.Views, nil
|
||||
*/
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocks(blocks []*model.Block, modifiedByID string) ([]*model.Block, error) {
|
||||
return a.InsertBlocksAndNotify(blocks, modifiedByID, false)
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocksAndNotify(blocks []*model.Block, modifiedByID string, disableNotify bool) ([]*model.Block, error) {
|
||||
if len(blocks) == 0 {
|
||||
return []*model.Block{}, nil
|
||||
}
|
||||
|
||||
// all blocks must belong to the same board
|
||||
boardID := blocks[0].BoardID
|
||||
for _, block := range blocks {
|
||||
if block.BoardID != boardID {
|
||||
return nil, ErrBlocksFromMultipleBoards
|
||||
}
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
needsNotify := make([]*model.Block, 0, len(blocks))
|
||||
for i := range blocks {
|
||||
// this check is needed to whitelist inbuilt template
|
||||
// initialization. They do contain more than 5 views per board.
|
||||
if boardID != "0" && blocks[i].Type == model.TypeView {
|
||||
withinLimit, err := a.isWithinViewsLimit(board.ID, blocks[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !withinLimit {
|
||||
a.logger.Info("views limit reached on board", mlog.String("board_id", blocks[i].ParentID), mlog.String("team_id", board.TeamID))
|
||||
return nil, model.ErrViewsLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
err := a.store.InsertBlock(blocks[i], modifiedByID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
needsNotify = append(needsNotify, blocks[i])
|
||||
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, blocks[i])
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
for _, b := range needsNotify {
|
||||
block := b
|
||||
a.webhook.NotifyUpdate(block)
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Add, block, nil, modifiedByID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after inserting blocks",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []*model.Block) error {
|
||||
// Images attached in cards have a path comprising the card's board ID.
|
||||
// When we create a template from this board, we need to copy the files
|
||||
// with the new board ID in path.
|
||||
// Not doing so causing images in templates (and boards created from this
|
||||
// template) to fail to load.
|
||||
|
||||
// look up ID of source sourceBoard, which may be different than the blocks.
|
||||
sourceBoard, err := a.GetBoard(sourceBoardID)
|
||||
if err != nil || sourceBoard == nil {
|
||||
return fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
|
||||
var destTeamID string
|
||||
var destBoardID string
|
||||
|
||||
for i := range copiedBlocks {
|
||||
block := copiedBlocks[i]
|
||||
fileName := ""
|
||||
isOk := false
|
||||
|
||||
switch block.Type {
|
||||
case model.TypeImage:
|
||||
fileName, isOk = block.Fields["fileId"].(string)
|
||||
if !isOk || fileName == "" {
|
||||
continue
|
||||
}
|
||||
case model.TypeAttachment:
|
||||
fileName, isOk = block.Fields["attachmentId"].(string)
|
||||
if !isOk || fileName == "" {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// create unique filename in case we are copying cards within the same board.
|
||||
ext := filepath.Ext(fileName)
|
||||
destFilename := utils.NewID(utils.IDTypeNone) + ext
|
||||
|
||||
if destBoardID == "" || block.BoardID != destBoardID {
|
||||
destBoardID = block.BoardID
|
||||
destBoard, err := a.GetBoard(destBoardID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
destTeamID = destBoard.TeamID
|
||||
}
|
||||
|
||||
sourceFilePath := filepath.Join(sourceBoard.TeamID, sourceBoard.ID, fileName)
|
||||
destinationFilePath := filepath.Join(destTeamID, block.BoardID, destFilename)
|
||||
|
||||
a.logger.Debug(
|
||||
"Copying card file",
|
||||
mlog.String("sourceFilePath", sourceFilePath),
|
||||
mlog.String("destinationFilePath", destinationFilePath),
|
||||
)
|
||||
|
||||
if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {
|
||||
a.logger.Error(
|
||||
"CopyCardFiles failed to copy file",
|
||||
mlog.String("sourceFilePath", sourceFilePath),
|
||||
mlog.String("destinationFilePath", destinationFilePath),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
if block.Type == model.TypeAttachment {
|
||||
block.Fields["attachmentId"] = destFilename
|
||||
parts := strings.Split(fileName, ".")
|
||||
fileInfoID := parts[0][1:]
|
||||
fileInfo, err := a.store.GetFileInfo(fileInfoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CopyCardFiles: cannot retrieve original fileinfo: %w", err)
|
||||
}
|
||||
newParts := strings.Split(destFilename, ".")
|
||||
newFileID := newParts[0][1:]
|
||||
fileInfo.Id = newFileID
|
||||
err = a.store.SaveFileInfo(fileInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err)
|
||||
}
|
||||
} else {
|
||||
block.Fields["fileId"] = destFilename
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetBlockByID(blockID string) (*model.Block, error) {
|
||||
return a.store.GetBlock(blockID)
|
||||
}
|
||||
|
||||
func (a *App) DeleteBlock(blockID string, modifiedBy string) error {
|
||||
return a.DeleteBlockAndNotify(blockID, modifiedBy, false)
|
||||
}
|
||||
|
||||
func (a *App) DeleteBlockAndNotify(blockID string, modifiedBy string, disableNotify bool) error {
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if block == nil {
|
||||
// deleting non-existing block not considered an error
|
||||
return nil
|
||||
}
|
||||
|
||||
err = a.store.DeleteBlock(blockID, modifiedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if block.Type == model.TypeImage {
|
||||
fileName, fileIDExists := block.Fields["fileId"]
|
||||
if fileName, fileIDIsString := fileName.(string); fileIDExists && fileIDIsString {
|
||||
filePath := filepath.Join(block.BoardID, fileName)
|
||||
err = a.filesBackend.RemoveFile(filePath)
|
||||
|
||||
if err != nil {
|
||||
a.logger.Error("Error deleting image file",
|
||||
mlog.String("FilePath", filePath),
|
||||
mlog.Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBlockDelete(board.TeamID, blockID, block.BoardID)
|
||||
a.metrics.IncrementBlocksDeleted(1)
|
||||
if !disableNotify {
|
||||
a.notifyBlockChanged(notify.Delete, block, block, modifiedBy)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting a block",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetLastBlockHistoryEntry(blockID string) (*model.Block, error) {
|
||||
blocks, err := a.store.GetBlockHistory(blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return blocks[0], nil
|
||||
}
|
||||
|
||||
func (a *App) UndeleteBlock(blockID string, modifiedBy string) (*model.Block, error) {
|
||||
blocks, err := a.store.GetBlockHistory(blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
// undeleting non-existing block not considered an error
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err = a.store.UndeleteBlock(blockID, modifiedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if model.IsErrNotFound(err) {
|
||||
a.logger.Error("Error loading the block after a successful undelete, not propagating through websockets or notifications", mlog.String("blockID", blockID))
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
a.webhook.NotifyUpdate(block)
|
||||
a.notifyBlockChanged(notify.Add, block, nil, modifiedBy)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after undeleting a block",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (a *App) GetBlockCountsByType() (map[string]int64, error) {
|
||||
return a.store.GetBlockCountsByType()
|
||||
}
|
||||
|
||||
func (a *App) GetBlocksForBoard(boardID string) ([]*model.Block, error) {
|
||||
return a.store.GetBlocksForBoard(boardID)
|
||||
}
|
||||
|
||||
func (a *App) notifyBlockChanged(action notify.Action, block *model.Block, oldBlock *model.Block, modifiedByID string) {
|
||||
// don't notify if notifications service disabled, or block change is generated via system user.
|
||||
if a.notifications == nil || modifiedByID == model.SystemUserID {
|
||||
return
|
||||
}
|
||||
|
||||
// find card and board for the changed block.
|
||||
board, card, err := a.getBoardAndCard(block)
|
||||
if err != nil {
|
||||
a.logger.Error("Error notifying for block change; cannot determine board or card", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
boardMember, _ := a.GetMemberForBoard(board.ID, modifiedByID)
|
||||
if boardMember == nil {
|
||||
// create temporary guest board member
|
||||
boardMember = &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: modifiedByID,
|
||||
}
|
||||
}
|
||||
|
||||
evt := notify.BlockChangeEvent{
|
||||
Action: action,
|
||||
TeamID: board.TeamID,
|
||||
Board: board,
|
||||
Card: card,
|
||||
BlockChanged: block,
|
||||
BlockOld: oldBlock,
|
||||
ModifiedBy: boardMember,
|
||||
}
|
||||
a.notifications.BlockChanged(evt)
|
||||
}
|
||||
|
||||
const (
|
||||
maxSearchDepth = 50
|
||||
)
|
||||
|
||||
// getBoardAndCard returns the first parent of type `card` its board for the specified block.
|
||||
// `board` and/or `card` may return nil without error if the block does not belong to a board or card.
|
||||
func (a *App) getBoardAndCard(block *model.Block) (board *model.Board, card *model.Block, err error) {
|
||||
board, err = a.store.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return board, card, err
|
||||
}
|
||||
|
||||
var count int // don't let invalid blocks hierarchy cause infinite loop.
|
||||
iter := block
|
||||
for {
|
||||
count++
|
||||
if card == nil && iter.Type == model.TypeCard {
|
||||
card = iter
|
||||
}
|
||||
|
||||
if iter.ParentID == "" || (board != nil && card != nil) || count > maxSearchDepth {
|
||||
break
|
||||
}
|
||||
|
||||
iter, err = a.store.GetBlock(iter.ParentID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return board, card, nil
|
||||
}
|
||||
if err != nil {
|
||||
return board, card, err
|
||||
}
|
||||
}
|
||||
return board, card, nil
|
||||
}
|
||||
417
server/boards/app/blocks_test.go
Обычный файл
417
server/boards/app/blocks_test.go
Обычный файл
@@ -0,0 +1,417 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type blockError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (be blockError) Error() string {
|
||||
return be.msg
|
||||
}
|
||||
|
||||
func TestInsertBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := &model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
err := th.App.InsertBlock(block, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := &model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(blockError{"error"})
|
||||
err := th.App.InsertBlock(block, "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchBlocks(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("patchBlocks success scenario", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{
|
||||
BlockIDs: []string{"block1"},
|
||||
BlockPatches: []model.BlockPatch{
|
||||
{Title: mm_model.NewString("new title")},
|
||||
},
|
||||
}
|
||||
|
||||
block1 := &model.Block{ID: "block1"}
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]*model.Block{block1}, nil)
|
||||
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(nil)
|
||||
th.Store.EXPECT().GetBlock("block1").Return(block1, nil)
|
||||
// this call comes from the WS server notification
|
||||
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("patchBlocks error scenario", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{BlockIDs: []string{}}
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
|
||||
t.Run("cloud limit error scenario", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
blockPatches := model.BlockPatchBatch{
|
||||
BlockIDs: []string{"block1"},
|
||||
BlockPatches: []model.BlockPatch{
|
||||
{Title: mm_model.NewString("new title")},
|
||||
},
|
||||
}
|
||||
|
||||
block1 := &model.Block{
|
||||
ID: "block1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board-id",
|
||||
BoardID: "board-id",
|
||||
UpdateAt: 100,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board-id",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]*model.Block{block1}, nil)
|
||||
th.Store.EXPECT().GetBoard("board-id").Return(board1, nil)
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.ErrorIs(t, err, model.ErrPatchUpdatesLimitedCards)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := &model.Block{
|
||||
ID: "block-id",
|
||||
BoardID: board.ID,
|
||||
}
|
||||
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
|
||||
th.Store.EXPECT().DeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(nil)
|
||||
th.Store.EXPECT().GetBoard(gomock.Eq(testBoardID)).Return(board, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
err := th.App.DeleteBlock("block-id", "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := &model.Block{
|
||||
ID: "block-id",
|
||||
BoardID: board.ID,
|
||||
}
|
||||
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
|
||||
th.Store.EXPECT().DeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"})
|
||||
th.Store.EXPECT().GetBoard(gomock.Eq(testBoardID)).Return(board, nil)
|
||||
err := th.App.DeleteBlock("block-id", "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUndeleteBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := &model.Block{
|
||||
ID: "block-id",
|
||||
BoardID: board.ID,
|
||||
}
|
||||
th.Store.EXPECT().GetBlockHistory(
|
||||
gomock.Eq("block-id"),
|
||||
gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}),
|
||||
).Return([]*model.Block{block}, nil)
|
||||
th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(nil)
|
||||
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
_, err := th.App.UndeleteBlock("block-id", "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
block := &model.Block{
|
||||
ID: "block-id",
|
||||
}
|
||||
th.Store.EXPECT().GetBlockHistory(
|
||||
gomock.Eq("block-id"),
|
||||
gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}),
|
||||
).Return([]*model.Block{block}, nil)
|
||||
th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"})
|
||||
_, err := th.App.UndeleteBlock("block-id", "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsWithinViewsLimit(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
t.Run("within views limit", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("view limit exactly reached", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(1),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("view limit already exceeded", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}, {}, {}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("creating first view", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("is not a cloud SKU so limits don't apply", func(t *testing.T) {
|
||||
nonCloudLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(false)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(nonCloudLicense)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInsertBlocks(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := &model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := &model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(blockError{"error"})
|
||||
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
|
||||
t.Run("create view within limits", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
boardID := testBoardID
|
||||
block := &model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}}, nil)
|
||||
|
||||
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("create view exceeding limits", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
boardID := testBoardID
|
||||
block := &model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}, {}}, nil)
|
||||
|
||||
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("creating multiple views, reaching limit in the process", func(t *testing.T) {
|
||||
t.Skipf("Will be fixed soon")
|
||||
|
||||
boardID := testBoardID
|
||||
view1 := &model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
view2 := &model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(view1, "user-id-1").Return(nil).Times(2)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense).Times(2)
|
||||
|
||||
cloudLimit := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{
|
||||
Views: mm_model.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil).Times(2)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil).Times(2)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil).Times(2)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}}, nil).Times(2)
|
||||
|
||||
_, err := th.App.InsertBlocks([]*model.Block{view1, view2}, "user-id-1")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
759
server/boards/app/boards.go
Обычный файл
759
server/boards/app/boards.go
Обычный файл
@@ -0,0 +1,759 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNewBoardCannotHaveID = errors.New("new board cannot have an ID")
|
||||
)
|
||||
|
||||
const linkBoardMessage = "@%s linked the board [%s](%s) with this channel"
|
||||
const unlinkBoardMessage = "@%s unlinked the board [%s](%s) with this channel"
|
||||
|
||||
var errNoDefaultCategoryFound = errors.New("no default category found for user")
|
||||
|
||||
func (a *App) GetBoard(boardID string) (*model.Board, error) {
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func (a *App) GetBoardCount() (int64, error) {
|
||||
return a.store.GetBoardCount()
|
||||
}
|
||||
|
||||
func (a *App) GetBoardMetadata(boardID string) (*model.Board, *model.BoardMetadata, error) {
|
||||
license := a.store.GetLicense()
|
||||
if license == nil || !(*license.Features.Compliance) {
|
||||
return nil, nil, model.ErrInsufficientLicense
|
||||
}
|
||||
|
||||
board, err := a.GetBoard(boardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
// Board may have been deleted, retrieve most recent history instead
|
||||
board, err = a.getBoardHistory(boardID, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
earliestTime, _, err := a.getBoardDescendantModifiedInfo(boardID, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
latestTime, lastModifiedBy, err := a.getBoardDescendantModifiedInfo(boardID, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
boardMetadata := model.BoardMetadata{
|
||||
BoardID: boardID,
|
||||
DescendantFirstUpdateAt: earliestTime,
|
||||
DescendantLastUpdateAt: latestTime,
|
||||
CreatedBy: board.CreatedBy,
|
||||
LastModifiedBy: lastModifiedBy,
|
||||
}
|
||||
return board, &boardMetadata, nil
|
||||
}
|
||||
|
||||
// getBoardForBlock returns the board that owns the specified block.
|
||||
func (a *App) getBoardForBlock(blockID string) (*model.Board, error) {
|
||||
block, err := a.GetBlockByID(blockID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get block %s: %w", blockID, err)
|
||||
}
|
||||
|
||||
board, err := a.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get board %s: %w", block.BoardID, err)
|
||||
}
|
||||
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func (a *App) getBoardHistory(boardID string, latest bool) (*model.Board, error) {
|
||||
opts := model.QueryBoardHistoryOptions{
|
||||
Limit: 1,
|
||||
Descending: latest,
|
||||
}
|
||||
boards, err := a.store.GetBoardHistory(boardID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get history for board: %w", err)
|
||||
}
|
||||
if len(boards) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return boards[0], nil
|
||||
}
|
||||
|
||||
func (a *App) getBoardDescendantModifiedInfo(boardID string, latest bool) (int64, string, error) {
|
||||
board, err := a.getBoardHistory(boardID, latest)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if board == nil {
|
||||
return 0, "", fmt.Errorf("history not found for board: %w", err)
|
||||
}
|
||||
|
||||
var timestamp int64
|
||||
modifiedBy := board.ModifiedBy
|
||||
if latest {
|
||||
timestamp = board.UpdateAt
|
||||
} else {
|
||||
timestamp = board.CreateAt
|
||||
}
|
||||
|
||||
// use block_history to fetch blocks in case they were deleted and no longer exist in blocks table.
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
Limit: 1,
|
||||
Descending: latest,
|
||||
}
|
||||
blocks, err := a.store.GetBlockHistoryDescendants(boardID, opts)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("could not get blocks history descendants for board: %w", err)
|
||||
}
|
||||
if len(blocks) > 0 {
|
||||
// Compare the board history info with the descendant block info, if it exists
|
||||
block := blocks[0]
|
||||
if latest && block.UpdateAt > timestamp {
|
||||
timestamp = block.UpdateAt
|
||||
modifiedBy = block.ModifiedBy
|
||||
} else if !latest && block.CreateAt < timestamp {
|
||||
timestamp = block.CreateAt
|
||||
modifiedBy = block.ModifiedBy
|
||||
}
|
||||
}
|
||||
return timestamp, modifiedBy, nil
|
||||
}
|
||||
|
||||
func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, userID, teamID string, asTemplate bool) error {
|
||||
// find source board's category ID for the user
|
||||
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var destinationCategoryID string
|
||||
|
||||
for _, categoryBoard := range userCategoryBoards {
|
||||
for _, metadata := range categoryBoard.BoardMetadata {
|
||||
if metadata.BoardID == sourceBoardID {
|
||||
// category found!
|
||||
destinationCategoryID = categoryBoard.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if destinationCategoryID == "" {
|
||||
// if source board is not mapped to a category for this user,
|
||||
// then move new board to default category
|
||||
if !asTemplate {
|
||||
return a.addBoardsToDefaultCategory(userID, teamID, []*model.Board{{ID: destinationBoardID}})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// now that we have source board's category,
|
||||
// we send destination board to the same category
|
||||
return a.AddUpdateUserCategoryBoard(teamID, userID, destinationCategoryID, []string{destinationBoardID})
|
||||
}
|
||||
|
||||
func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) {
|
||||
bab, members, err := a.store.DuplicateBoard(boardID, userID, toTeam, asTemplate)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// copy any file attachments from the duplicated blocks.
|
||||
if err = a.CopyCardFiles(boardID, bab.Blocks); err != nil {
|
||||
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err))
|
||||
}
|
||||
|
||||
if !asTemplate {
|
||||
for _, board := range bab.Boards {
|
||||
if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, toTeam, asTemplate); categoryErr != nil {
|
||||
return nil, nil, categoryErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bab.Blocks now has updated file ids for any blocks containing files. We need to store them.
|
||||
blockIDs := make([]string, 0)
|
||||
blockPatches := make([]model.BlockPatch, 0)
|
||||
|
||||
for _, block := range bab.Blocks {
|
||||
fieldName := ""
|
||||
if block.Type == model.TypeImage {
|
||||
fieldName = "fileId"
|
||||
} else if block.Type == model.TypeAttachment {
|
||||
fieldName = "attachmentId"
|
||||
}
|
||||
if fieldName != "" {
|
||||
if fieldID, ok := block.Fields[fieldName]; ok {
|
||||
blockIDs = append(blockIDs, block.ID)
|
||||
blockPatches = append(blockPatches, model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{
|
||||
fieldName: fieldID,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
a.logger.Debug("Duplicate boards patching file IDs", mlog.Int("count", len(blockIDs)))
|
||||
|
||||
if len(blockIDs) != 0 {
|
||||
patches := &model.BlockPatchBatch{
|
||||
BlockIDs: blockIDs,
|
||||
BlockPatches: blockPatches,
|
||||
}
|
||||
if err = a.store.PatchBlocks(patches, userID); err != nil {
|
||||
dbab := model.NewDeleteBoardsAndBlocksFromBabs(bab)
|
||||
if err = a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil {
|
||||
a.logger.Error("Cannot delete board after duplication error when updating block's file info", mlog.String("boardID", bab.Boards[0].ID), mlog.Err(err))
|
||||
}
|
||||
return nil, nil, fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err)
|
||||
}
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
teamID := ""
|
||||
for _, board := range bab.Boards {
|
||||
teamID = board.TeamID
|
||||
a.wsAdapter.BroadcastBoardChange(teamID, board)
|
||||
}
|
||||
for _, block := range bab.Blocks {
|
||||
blk := block
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, blk)
|
||||
a.notifyBlockChanged(notify.Add, blk, nil, userID)
|
||||
}
|
||||
for _, member := range members {
|
||||
a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(bab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after duplicating a board",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return bab, members, err
|
||||
}
|
||||
|
||||
func (a *App) GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return a.store.GetBoardsForUserAndTeam(userID, teamID, includePublicBoards)
|
||||
}
|
||||
|
||||
func (a *App) GetTemplateBoards(teamID, userID string) ([]*model.Board, error) {
|
||||
return a.store.GetTemplateBoards(teamID, userID)
|
||||
}
|
||||
|
||||
func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) {
|
||||
if board.ID != "" {
|
||||
return nil, ErrNewBoardCannotHaveID
|
||||
}
|
||||
board.ID = utils.NewID(utils.IDTypeBoard)
|
||||
|
||||
var newBoard *model.Board
|
||||
var member *model.BoardMember
|
||||
var err error
|
||||
if addMember {
|
||||
newBoard, member, err = a.store.InsertBoardWithAdmin(board, userID)
|
||||
} else {
|
||||
newBoard, err = a.store.InsertBoard(board, userID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(newBoard.TeamID, newBoard)
|
||||
|
||||
if newBoard.ChannelID != "" {
|
||||
members, err := a.GetMembersForBoard(board.ID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board members", mlog.Err(err))
|
||||
}
|
||||
for _, member := range members {
|
||||
a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, member.BoardID, member)
|
||||
}
|
||||
} else if addMember {
|
||||
a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, newBoard.ID, member)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if !board.IsTemplate {
|
||||
if err := a.addBoardsToDefaultCategory(userID, newBoard.TeamID, []*model.Board{newBoard}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return newBoard, nil
|
||||
}
|
||||
|
||||
func (a *App) addBoardsToDefaultCategory(userID, teamID string, boards []*model.Board) error {
|
||||
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaultCategoryID := ""
|
||||
for _, categoryBoard := range userCategoryBoards {
|
||||
if categoryBoard.Name == defaultCategoryBoards {
|
||||
defaultCategoryID = categoryBoard.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if defaultCategoryID == "" {
|
||||
return fmt.Errorf("%w userID: %s", errNoDefaultCategoryFound, userID)
|
||||
}
|
||||
|
||||
boardIDs := make([]string, len(boards))
|
||||
for i := range boards {
|
||||
boardIDs[i] = boards[i].ID
|
||||
}
|
||||
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*model.Board, error) {
|
||||
var oldChannelID string
|
||||
var isTemplate bool
|
||||
var oldMembers []*model.BoardMember
|
||||
|
||||
if patch.Type != nil || patch.ChannelID != nil {
|
||||
testChannel := ""
|
||||
if patch.ChannelID != nil && *patch.ChannelID == "" {
|
||||
var err error
|
||||
oldMembers, err = a.GetMembersForBoard(boardID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board members", mlog.Err(err))
|
||||
}
|
||||
} else if patch.ChannelID != nil && *patch.ChannelID != "" {
|
||||
testChannel = *patch.ChannelID
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, model.NewErrNotFound("board ID=" + boardID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldChannelID = board.ChannelID
|
||||
isTemplate = board.IsTemplate
|
||||
if testChannel == "" {
|
||||
testChannel = oldChannelID
|
||||
}
|
||||
|
||||
if testChannel != "" {
|
||||
if !a.permissions.HasPermissionToChannel(userID, testChannel, model.PermissionCreatePost) {
|
||||
return nil, model.NewErrPermission("access denied to channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
updatedBoard, err := a.store.PatchBoard(boardID, patch, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Post message to channel if linked/unlinked
|
||||
if patch.ChannelID != nil {
|
||||
var username string
|
||||
|
||||
user, err := a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board updater", mlog.Err(err))
|
||||
username = "unknown"
|
||||
} else {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
boardLink := utils.MakeBoardLink(a.config.ServerRoot, updatedBoard.TeamID, updatedBoard.ID)
|
||||
title := updatedBoard.Title
|
||||
if title == "" {
|
||||
title = "Untitled board" // todo: localize this when server has i18n
|
||||
}
|
||||
if *patch.ChannelID != "" {
|
||||
a.postChannelMessage(fmt.Sprintf(linkBoardMessage, username, title, boardLink), updatedBoard.ChannelID)
|
||||
} else if *patch.ChannelID == "" {
|
||||
a.postChannelMessage(fmt.Sprintf(unlinkBoardMessage, username, title, boardLink), oldChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast Messages to affected users
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(updatedBoard.TeamID, updatedBoard)
|
||||
|
||||
if patch.ChannelID != nil {
|
||||
if *patch.ChannelID != "" {
|
||||
members, err := a.GetMembersForBoard(updatedBoard.ID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board members", mlog.Err(err))
|
||||
}
|
||||
for _, member := range members {
|
||||
if member.Synthetic {
|
||||
a.wsAdapter.BroadcastMemberChange(updatedBoard.TeamID, member.BoardID, member)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, oldMember := range oldMembers {
|
||||
if oldMember.Synthetic {
|
||||
a.wsAdapter.BroadcastMemberDelete(updatedBoard.TeamID, boardID, oldMember.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if patch.Type != nil && isTemplate {
|
||||
members, err := a.GetMembersForBoard(updatedBoard.ID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the board members", mlog.Err(err))
|
||||
}
|
||||
a.broadcastTeamUsers(updatedBoard.TeamID, updatedBoard.ID, *patch.Type, members)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return updatedBoard, nil
|
||||
}
|
||||
|
||||
func (a *App) postChannelMessage(message, channelID string) {
|
||||
err := a.store.PostMessage(message, "", channelID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to post the link message to channel", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastTeamUsers notifies the members of a team when a template changes its type
|
||||
// from public to private or viceversa.
|
||||
func (a *App) broadcastTeamUsers(teamID, boardID string, boardType model.BoardType, members []*model.BoardMember) {
|
||||
users, err := a.GetTeamUsers(teamID, "")
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get the team users", mlog.Err(err))
|
||||
}
|
||||
for _, user := range users {
|
||||
isMember := false
|
||||
for _, member := range members {
|
||||
if member.UserID == user.ID {
|
||||
isMember = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMember {
|
||||
if boardType == model.BoardTypePrivate {
|
||||
a.wsAdapter.BroadcastMemberDelete(teamID, boardID, user.ID)
|
||||
} else if boardType == model.BoardTypeOpen {
|
||||
a.wsAdapter.BroadcastMemberChange(teamID, boardID, &model.BoardMember{UserID: user.ID, BoardID: boardID, SchemeViewer: true, Synthetic: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) DeleteBoard(boardID, userID string) error {
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.store.DeleteBoard(boardID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardDelete(board.TeamID, boardID)
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting a board",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) {
|
||||
members, err := a.store.GetMembersForBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if board != nil {
|
||||
for i, m := range members {
|
||||
if !m.SchemeAdmin {
|
||||
if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) {
|
||||
members[i].SchemeAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (a *App) GetMembersForUser(userID string) ([]*model.BoardMember, error) {
|
||||
members, err := a.store.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, m := range members {
|
||||
if !m.SchemeAdmin {
|
||||
board, err := a.store.GetBoard(m.BoardID)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if board != nil {
|
||||
if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) {
|
||||
// if system/team admin
|
||||
members[i].SchemeAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (a *App) GetMemberForBoard(boardID string, userID string) (*model.BoardMember, error) {
|
||||
return a.store.GetMemberForBoard(boardID, userID)
|
||||
}
|
||||
|
||||
func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) {
|
||||
board, err := a.store.GetBoard(member.BoardID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingMembership, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingMembership != nil && !existingMembership.Synthetic {
|
||||
return existingMembership, nil
|
||||
}
|
||||
|
||||
newMember, err := a.store.SaveMember(member)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !newMember.SchemeAdmin {
|
||||
if board != nil {
|
||||
if a.permissions.HasPermissionToTeam(newMember.UserID, board.TeamID, model.PermissionManageTeam) {
|
||||
newMember.SchemeAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !board.IsTemplate {
|
||||
if err = a.addBoardsToDefaultCategory(member.UserID, board.TeamID, []*model.Board{board}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
|
||||
return nil
|
||||
})
|
||||
|
||||
return newMember, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember, error) {
|
||||
board, bErr := a.store.GetBoard(member.BoardID)
|
||||
if model.IsErrNotFound(bErr) {
|
||||
return nil, nil
|
||||
}
|
||||
if bErr != nil {
|
||||
return nil, bErr
|
||||
}
|
||||
|
||||
oldMember, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// if we're updating an admin, we need to check that there is at
|
||||
// least still another admin on the board
|
||||
if oldMember.SchemeAdmin && !member.SchemeAdmin {
|
||||
isLastAdmin, err2 := a.isLastAdmin(member.UserID, member.BoardID)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
if isLastAdmin {
|
||||
return nil, model.ErrBoardMemberIsLastAdmin
|
||||
}
|
||||
}
|
||||
|
||||
newMember, err := a.store.SaveMember(member)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
|
||||
return nil
|
||||
})
|
||||
|
||||
return newMember, nil
|
||||
}
|
||||
|
||||
func (a *App) isLastAdmin(userID, boardID string) (bool, error) {
|
||||
members, err := a.store.GetMembersForBoard(boardID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
if m.SchemeAdmin && m.UserID != userID {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteBoardMember(boardID, userID string) error {
|
||||
board, bErr := a.store.GetBoard(boardID)
|
||||
if model.IsErrNotFound(bErr) {
|
||||
return nil
|
||||
}
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
|
||||
oldMember, err := a.store.GetMemberForBoard(boardID, userID)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if we're removing an admin, we need to check that there is at
|
||||
// least still another admin on the board
|
||||
if oldMember.SchemeAdmin {
|
||||
isLastAdmin, err := a.isLastAdmin(userID, boardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isLastAdmin {
|
||||
return model.ErrBoardMemberIsLastAdmin
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.store.DeleteMember(boardID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
if syntheticMember, _ := a.GetMemberForBoard(boardID, userID); syntheticMember != nil {
|
||||
a.wsAdapter.BroadcastMemberChange(board.TeamID, boardID, syntheticMember)
|
||||
} else {
|
||||
a.wsAdapter.BroadcastMemberDelete(board.TeamID, boardID, userID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, searchField, userID, includePublicBoards)
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUserInTeam(teamID, term, userID)
|
||||
}
|
||||
|
||||
func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
|
||||
boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(boards) == 0 {
|
||||
// undeleting non-existing board not considered an error
|
||||
return nil
|
||||
}
|
||||
|
||||
err = a.store.UndeleteBoard(boardID, modifiedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if board == nil {
|
||||
a.logger.Error("Error loading the board after undelete, not propagating through websockets or notifications")
|
||||
return nil
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(board.TeamID, board)
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after undeleting a board",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
170
server/boards/app/boards_and_blocks.go
Обычный файл
170
server/boards/app/boards_and_blocks.go
Обычный файл
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, addMember bool) (*model.BoardsAndBlocks, error) {
|
||||
var newBab *model.BoardsAndBlocks
|
||||
var members []*model.BoardMember
|
||||
var err error
|
||||
|
||||
if addMember {
|
||||
newBab, members, err = a.store.CreateBoardsAndBlocksWithAdmin(bab, userID)
|
||||
} else {
|
||||
newBab, err = a.store.CreateBoardsAndBlocks(bab, userID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// all new boards should belong to the same team
|
||||
teamID := newBab.Boards[0].TeamID
|
||||
|
||||
// This can be synchronous because this action is not common
|
||||
for _, board := range newBab.Boards {
|
||||
a.wsAdapter.BroadcastBoardChange(teamID, board)
|
||||
}
|
||||
|
||||
for _, block := range newBab.Blocks {
|
||||
b := block
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, b)
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
a.webhook.NotifyUpdate(b)
|
||||
a.notifyBlockChanged(notify.Add, b, nil, userID)
|
||||
}
|
||||
|
||||
if addMember {
|
||||
for _, member := range members {
|
||||
a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newBab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after creating boards and blocks",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, board := range newBab.Boards {
|
||||
if !board.IsTemplate {
|
||||
if err := a.addBoardsToDefaultCategory(userID, board.TeamID, []*model.Board{board}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newBab, nil
|
||||
}
|
||||
|
||||
func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) {
|
||||
oldBlocks, err := a.store.GetBlocksByIDs(pbab.BlockIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, cErr := a.ContainsLimitedBlocks(oldBlocks)
|
||||
if cErr != nil {
|
||||
return nil, cErr
|
||||
}
|
||||
if containsLimitedBlocks {
|
||||
return nil, model.ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
oldBlocksMap := map[string]*model.Block{}
|
||||
for _, block := range oldBlocks {
|
||||
oldBlocksMap[block.ID] = block
|
||||
}
|
||||
|
||||
bab, err := a.store.PatchBoardsAndBlocks(pbab, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
teamID := bab.Boards[0].TeamID
|
||||
|
||||
for _, block := range bab.Blocks {
|
||||
oldBlock, ok := oldBlocksMap[block.ID]
|
||||
if !ok {
|
||||
a.logger.Error("Error notifying for block change on patch boards and blocks; cannot get old block", mlog.String("blockID", block.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
b := block
|
||||
a.metrics.IncrementBlocksPatched(1)
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, b)
|
||||
a.webhook.NotifyUpdate(b)
|
||||
a.notifyBlockChanged(notify.Update, b, oldBlock, userID)
|
||||
}
|
||||
|
||||
for _, board := range bab.Boards {
|
||||
a.wsAdapter.BroadcastBoardChange(board.TeamID, board)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return bab, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID string) error {
|
||||
firstBoard, err := a.store.GetBoard(dbab.Boards[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// we need the block entity to notify of the block changes, so we
|
||||
// fetch and store the blocks first
|
||||
blocks := []*model.Block{}
|
||||
for _, blockID := range dbab.Blocks {
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
|
||||
if err := a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
for _, block := range blocks {
|
||||
a.wsAdapter.BroadcastBlockDelete(firstBoard.TeamID, block.ID, block.BoardID)
|
||||
a.metrics.IncrementBlocksDeleted(1)
|
||||
a.notifyBlockChanged(notify.Update, block, block, userID)
|
||||
}
|
||||
|
||||
for _, boardID := range dbab.Boards {
|
||||
a.wsAdapter.BroadcastBoardDelete(firstBoard.TeamID, boardID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(dbab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting boards and blocks",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
779
server/boards/app/boards_test.go
Обычный файл
779
server/boards/app/boards_test.go
Обычный файл
@@ -0,0 +1,779 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestAddMemberToBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: "board_id_1",
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(nil, nil)
|
||||
|
||||
th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool {
|
||||
p := i.(*model.BoardMember)
|
||||
return p.BoardID == boardID && p.UserID == userID
|
||||
})).Return(&model.BoardMember{
|
||||
BoardID: boardID,
|
||||
}, nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
|
||||
t.Run("return existing non-synthetic membership if any", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: false,
|
||||
}, nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
|
||||
t.Run("should convert synthetic membership into natural membership", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: "board_id_1",
|
||||
TeamID: "team_id_1",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: true,
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool {
|
||||
p := i.(*model.BoardMember)
|
||||
return p.BoardID == boardID && p.UserID == userID
|
||||
})).Return(&model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
Synthetic: false,
|
||||
}, nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
|
||||
th.API.EXPECT().HasPermissionToTeam("user_id_1", "team_id_1", model.PermissionManageTeam).Return(false).Times(1)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, addedBoardMember.BoardID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case, title patch", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchTitle := "Patched Title"
|
||||
patch := &model.BoardPatch{
|
||||
Title: &patchTitle,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
Title: patchTitle,
|
||||
},
|
||||
nil)
|
||||
|
||||
// for WS BroadcastBoardChange
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(1)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, patchTitle, patchedBoard.Title)
|
||||
})
|
||||
|
||||
t.Run("patch type open, no users", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(2)
|
||||
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil)
|
||||
th.Store.EXPECT().GetUserByID(userID).Return(&model.User{ID: userID, Username: "UserName"}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// - for WS BroadcastBoardChange
|
||||
// - for AddTeamMembers check
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type private, no users", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypePrivate
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(2)
|
||||
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// - for WS BroadcastBoardChange
|
||||
// - for AddTeamMembers check
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type open, single user", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(2)
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 3 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// for WS BroadcastMemberChange
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type private, single user", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypePrivate
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(2)
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 3 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// for WS BroadcastMemberChange
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type open, user with member", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(3)
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
|
||||
t.Run("patch type private, user with member", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
patchType := model.BoardTypePrivate
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
ChannelID: "",
|
||||
}, nil).Times(1)
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
|
||||
// Type not null will retrieve team members
|
||||
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
t.Run("patch type channel, user without post permissions", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
channelID := "myChannel"
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
ChannelID: &channelID,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}, nil).Times(1)
|
||||
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
|
||||
_, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("patch type channel, user with post permissions", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
channelID := "myChannel"
|
||||
patch := &model.BoardPatch{
|
||||
ChannelID: &channelID,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
}, nil).Times(2)
|
||||
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1)
|
||||
|
||||
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
|
||||
&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
},
|
||||
nil)
|
||||
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// - for WS BroadcastBoardChange
|
||||
// - for AddTeamMembers check
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
th.Store.EXPECT().PostMessage(utils.Anything, "", "").Times(1)
|
||||
|
||||
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, patchedBoard.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchBoard2(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("patch type remove channel, user without post permissions", func(t *testing.T) {
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_2"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
const channelID = "myChannel"
|
||||
clearChannel := ""
|
||||
patchType := model.BoardTypeOpen
|
||||
patch := &model.BoardPatch{
|
||||
Type: &patchType,
|
||||
ChannelID: &clearChannel,
|
||||
}
|
||||
|
||||
// Type not nil, will cause board to be reteived
|
||||
// to check isTemplate
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
ChannelID: channelID,
|
||||
}, nil).Times(2)
|
||||
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
// Should call GetMembersForBoard 2 times
|
||||
// for WS BroadcastBoardChange
|
||||
// for AddTeamMembers check
|
||||
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).AnyTimes()
|
||||
|
||||
_, err := th.App.PatchBoard(patch, boardID, userID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetBoardCount(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
boardCount := int64(100)
|
||||
th.Store.EXPECT().GetBoardCount().Return(boardCount, nil)
|
||||
|
||||
count, err := th.App.GetBoardCount()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardCount, count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoardCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("no boards default category exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1"},
|
||||
{BoardID: "board_id_2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
}, nil).Times(1)
|
||||
|
||||
// when this function is called the second time, the default category is created
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1"},
|
||||
{BoardID: "board_id_2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "default_category_id", Type: model.CategoryTypeSystem, Name: "Boards"},
|
||||
},
|
||||
}, nil).Times(1)
|
||||
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", []string{
|
||||
"board_id_1",
|
||||
"board_id_2",
|
||||
"board_id_3",
|
||||
}).Return(nil)
|
||||
|
||||
boards := []*model.Board{
|
||||
{ID: "board_id_1"},
|
||||
{ID: "board_id_2"},
|
||||
{ID: "board_id_3"},
|
||||
}
|
||||
|
||||
err := th.App.addBoardsToDefaultCategory("user_id", "team_id", boards)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDuplicateBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
board := &model.Board{
|
||||
ID: "board_id_2",
|
||||
Title: "Duplicated Board",
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: "block_id_1",
|
||||
Type: "image",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false).Return(
|
||||
&model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{
|
||||
board,
|
||||
},
|
||||
Blocks: []*model.Block{
|
||||
block,
|
||||
},
|
||||
},
|
||||
[]*model.BoardMember{},
|
||||
nil,
|
||||
)
|
||||
|
||||
th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
}, nil).Times(3)
|
||||
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "category_id_1", utils.Anything).Return(nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bab)
|
||||
assert.NotNil(t, members)
|
||||
})
|
||||
|
||||
t.Run("duplicating board as template should not set it's category", func(t *testing.T) {
|
||||
board := &model.Board{
|
||||
ID: "board_id_2",
|
||||
Title: "Duplicated Board",
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: "block_id_1",
|
||||
Type: "image",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", true).Return(
|
||||
&model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{
|
||||
board,
|
||||
},
|
||||
Blocks: []*model.Block{
|
||||
block,
|
||||
},
|
||||
},
|
||||
[]*model.BoardMember{},
|
||||
nil,
|
||||
)
|
||||
|
||||
th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", true)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bab)
|
||||
assert.NotNil(t, members)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMembersForBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
},
|
||||
}, nil).Times(3)
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(nil, nil).Times(1)
|
||||
t.Run("-base case", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForBoard(boardID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
assert.False(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
board := &model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2)
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
|
||||
t.Run("-team check false ", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForBoard(boardID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
|
||||
assert.False(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
|
||||
t.Run("-team check true", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForBoard(boardID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
|
||||
assert.True(t, members[0].SchemeAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMembersForUser(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
const boardID = "board_id_1"
|
||||
const userID = "user_id_1"
|
||||
const teamID = "team_id_1"
|
||||
|
||||
th.Store.EXPECT().GetMembersForUser(userID).Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
SchemeEditor: true,
|
||||
},
|
||||
}, nil).Times(3)
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(nil, nil)
|
||||
t.Run("-base case", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForUser(userID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
assert.False(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
board := &model.Board{
|
||||
ID: boardID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2)
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
t.Run("-team check false ", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForUser(userID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
|
||||
assert.False(t, members[0].SchemeAdmin)
|
||||
})
|
||||
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
|
||||
t.Run("-team check true", func(t *testing.T) {
|
||||
members, err := th.App.GetMembersForUser(userID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, members)
|
||||
|
||||
assert.True(t, members[0].SchemeAdmin)
|
||||
})
|
||||
}
|
||||
96
server/boards/app/cards.go
Обычный файл
96
server/boards/app/cards.go
Обычный файл
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func (a *App) CreateCard(card *model.Card, boardID string, userID string, disableNotify bool) (*model.Card, error) {
|
||||
// Convert the card struct to a block and insert the block.
|
||||
now := utils.GetMillis()
|
||||
|
||||
card.ID = utils.NewID(utils.IDTypeCard)
|
||||
card.BoardID = boardID
|
||||
card.CreatedBy = userID
|
||||
card.ModifiedBy = userID
|
||||
card.CreateAt = now
|
||||
card.UpdateAt = now
|
||||
card.DeleteAt = 0
|
||||
|
||||
block := model.Card2Block(card)
|
||||
|
||||
newBlocks, err := a.InsertBlocksAndNotify([]*model.Block{block}, userID, disableNotify)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create card: %w", err)
|
||||
}
|
||||
|
||||
newCard, err := model.Block2Card(newBlocks[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCard, nil
|
||||
}
|
||||
|
||||
func (a *App) GetCardsForBoard(boardID string, page int, perPage int) ([]*model.Card, error) {
|
||||
opts := model.QueryBlocksOptions{
|
||||
BoardID: boardID,
|
||||
BlockType: model.TypeCard,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}
|
||||
|
||||
blocks, err := a.store.GetBlocks(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]*model.Card, 0, len(blocks))
|
||||
var card *model.Card
|
||||
for _, blk := range blocks {
|
||||
b := blk
|
||||
if card, err = model.Block2Card(b); err != nil {
|
||||
return nil, fmt.Errorf("Block2Card fail: %w", err)
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (a *App) PatchCard(cardPatch *model.CardPatch, cardID string, userID string, disableNotify bool) (*model.Card, error) {
|
||||
blockPatch, err := model.CardPatch2BlockPatch(cardPatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newBlock, err := a.PatchBlockAndNotify(cardID, blockPatch, userID, disableNotify)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot patch card %s: %w", cardID, err)
|
||||
}
|
||||
|
||||
newCard, err := model.Block2Card(newBlock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCard, nil
|
||||
}
|
||||
|
||||
func (a *App) GetCardByID(cardID string) (*model.Card, error) {
|
||||
cardBlock, err := a.GetBlockByID(cardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
card, err := model.Block2Card(cardBlock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return card, nil
|
||||
}
|
||||
270
server/boards/app/cards_test.go
Обычный файл
270
server/boards/app/cards_test.go
Обычный файл
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func TestCreateCard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board := &model.Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
}
|
||||
userID := utils.NewID(utils.IDTypeUser)
|
||||
|
||||
props := makeProps(3)
|
||||
|
||||
card := &model.Card{
|
||||
BoardID: board.ID,
|
||||
CreatedBy: userID,
|
||||
ModifiedBy: userID,
|
||||
Title: "test card",
|
||||
ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)},
|
||||
Properties: props,
|
||||
}
|
||||
block := model.Card2Block(card)
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(gomock.AssignableToTypeOf(reflect.TypeOf(block)), userID).Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
newCard, err := th.App.CreateCard(card, board.ID, userID, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, card.BoardID, newCard.BoardID)
|
||||
require.Equal(t, card.Title, newCard.Title)
|
||||
require.Equal(t, card.ContentOrder, newCard.ContentOrder)
|
||||
require.EqualValues(t, card.Properties, newCard.Properties)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(gomock.AssignableToTypeOf(reflect.TypeOf(block)), userID).Return(blockError{"error"})
|
||||
|
||||
newCard, err := th.App.CreateCard(card, board.ID, userID, false)
|
||||
|
||||
require.Error(t, err, "error")
|
||||
require.Nil(t, newCard)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetCards(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board := &model.Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
}
|
||||
|
||||
const cardCount = 25
|
||||
|
||||
// make some cards
|
||||
blocks := make([]*model.Block, 0, cardCount)
|
||||
for i := 0; i < cardCount; i++ {
|
||||
card := &model.Block{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
ParentID: board.ID,
|
||||
Schema: 1,
|
||||
Type: model.TypeCard,
|
||||
Title: fmt.Sprintf("card %d", i),
|
||||
BoardID: board.ID,
|
||||
}
|
||||
blocks = append(blocks, card)
|
||||
}
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
opts := model.QueryBlocksOptions{
|
||||
BoardID: board.ID,
|
||||
BlockType: model.TypeCard,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBlocks(opts).Return(blocks, nil)
|
||||
|
||||
cards, err := th.App.GetCardsForBoard(board.ID, 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cards, cardCount)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
opts := model.QueryBlocksOptions{
|
||||
BoardID: board.ID,
|
||||
BlockType: model.TypeCard,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBlocks(opts).Return(nil, blockError{"error"})
|
||||
|
||||
cards, err := th.App.GetCardsForBoard(board.ID, 0, 0)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, cards)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchCard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board := &model.Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
}
|
||||
userID := utils.NewID(utils.IDTypeUser)
|
||||
|
||||
props := makeProps(3)
|
||||
|
||||
card := &model.Card{
|
||||
BoardID: board.ID,
|
||||
CreatedBy: userID,
|
||||
ModifiedBy: userID,
|
||||
Title: "test card for patch",
|
||||
ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)},
|
||||
Properties: copyProps(props),
|
||||
}
|
||||
|
||||
newTitle := "patched"
|
||||
newIcon := "😀"
|
||||
newContentOrder := reverse(card.ContentOrder)
|
||||
|
||||
cardPatch := &model.CardPatch{
|
||||
Title: &newTitle,
|
||||
ContentOrder: &newContentOrder,
|
||||
Icon: &newIcon,
|
||||
UpdatedProperties: modifyProps(props),
|
||||
}
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
expectedPatchedCard := cardPatch.Patch(card)
|
||||
expectedPatchedBlock := model.Card2Block(expectedPatchedCard)
|
||||
|
||||
var blockPatch *model.BlockPatch
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().PatchBlock(card.ID, gomock.AssignableToTypeOf(reflect.TypeOf(blockPatch)), userID).Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBlock(card.ID).Return(expectedPatchedBlock, nil).AnyTimes()
|
||||
|
||||
patchedCard, err := th.App.PatchCard(cardPatch, card.ID, userID, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, board.ID, patchedCard.BoardID)
|
||||
require.Equal(t, newTitle, patchedCard.Title)
|
||||
require.Equal(t, newIcon, patchedCard.Icon)
|
||||
require.Equal(t, newContentOrder, patchedCard.ContentOrder)
|
||||
require.EqualValues(t, expectedPatchedCard.Properties, patchedCard.Properties)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
var blockPatch *model.BlockPatch
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().PatchBlock(card.ID, gomock.AssignableToTypeOf(reflect.TypeOf(blockPatch)), userID).Return(blockError{"error"})
|
||||
|
||||
patchedCard, err := th.App.PatchCard(cardPatch, card.ID, userID, false)
|
||||
|
||||
require.Error(t, err, "error")
|
||||
require.Nil(t, patchedCard)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetCard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
boardID := utils.NewID(utils.IDTypeBoard)
|
||||
userID := utils.NewID(utils.IDTypeUser)
|
||||
props := makeProps(5)
|
||||
contentOrder := []string{utils.NewID(utils.IDTypeUser), utils.NewID(utils.IDTypeUser)}
|
||||
fields := make(map[string]any)
|
||||
fields["contentOrder"] = contentOrder
|
||||
fields["properties"] = props
|
||||
fields["icon"] = "😀"
|
||||
fields["isTemplate"] = true
|
||||
|
||||
block := &model.Block{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
ParentID: boardID,
|
||||
Type: model.TypeCard,
|
||||
Title: "test card",
|
||||
BoardID: boardID,
|
||||
Fields: fields,
|
||||
CreatedBy: userID,
|
||||
ModifiedBy: userID,
|
||||
}
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBlock(block.ID).Return(block, nil)
|
||||
|
||||
card, err := th.App.GetCardByID(block.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, boardID, card.BoardID)
|
||||
require.Equal(t, block.Title, card.Title)
|
||||
require.Equal(t, "😀", card.Icon)
|
||||
require.Equal(t, true, card.IsTemplate)
|
||||
require.Equal(t, contentOrder, card.ContentOrder)
|
||||
require.EqualValues(t, props, card.Properties)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
bogusID := utils.NewID(utils.IDTypeBlock)
|
||||
th.Store.EXPECT().GetBlock(bogusID).Return(nil, model.NewErrNotFound(bogusID))
|
||||
|
||||
card, err := th.App.GetCardByID(bogusID)
|
||||
|
||||
require.Error(t, err, "error")
|
||||
require.True(t, model.IsErrNotFound(err))
|
||||
require.Nil(t, card)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBlock(block.ID).Return(nil, blockError{"error"})
|
||||
|
||||
card, err := th.App.GetCardByID(block.ID)
|
||||
|
||||
require.Error(t, err, "error")
|
||||
require.Nil(t, card)
|
||||
})
|
||||
}
|
||||
|
||||
// reverse is a helper function to copy and reverse a slice of strings.
|
||||
func reverse(src []string) []string {
|
||||
out := make([]string, 0, len(src))
|
||||
for i := len(src) - 1; i >= 0; i-- {
|
||||
out = append(out, src[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func makeProps(count int) map[string]any {
|
||||
props := make(map[string]any)
|
||||
for i := 0; i < count; i++ {
|
||||
props[utils.NewID(utils.IDTypeBlock)] = utils.NewID(utils.IDTypeBlock)
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
func copyProps(m map[string]any) map[string]any {
|
||||
out := make(map[string]any)
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func modifyProps(m map[string]any) map[string]any {
|
||||
out := make(map[string]any)
|
||||
for k := range m {
|
||||
out[k] = utils.NewID(utils.IDTypeBlock)
|
||||
}
|
||||
return out
|
||||
}
|
||||
248
server/boards/app/category.go
Обычный файл
248
server/boards/app/category.go
Обычный файл
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
var errCategoryNotFound = errors.New("category ID specified in input does not exist for user")
|
||||
var errCategoriesLengthMismatch = errors.New("cannot update category order, passed list of categories different size than in database")
|
||||
var ErrCannotDeleteSystemCategory = errors.New("cannot delete a system category")
|
||||
var ErrCannotUpdateSystemCategory = errors.New("cannot update a system category")
|
||||
|
||||
func (a *App) GetCategory(categoryID string) (*model.Category, error) {
|
||||
return a.store.GetCategory(categoryID)
|
||||
}
|
||||
|
||||
func (a *App) CreateCategory(category *model.Category) (*model.Category, error) {
|
||||
category.Hydrate()
|
||||
if err := category.IsValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := a.store.CreateCategory(*category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdCategory, err := a.store.GetCategory(category.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryChange(*createdCategory)
|
||||
}()
|
||||
|
||||
return createdCategory, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateCategory(category *model.Category) (*model.Category, error) {
|
||||
category.Hydrate()
|
||||
|
||||
if err := category.IsValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// verify if category belongs to the user
|
||||
existingCategory, err := a.store.GetCategory(category.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingCategory.DeleteAt != 0 {
|
||||
return nil, model.ErrCategoryDeleted
|
||||
}
|
||||
|
||||
if existingCategory.UserID != category.UserID {
|
||||
return nil, model.ErrCategoryPermissionDenied
|
||||
}
|
||||
|
||||
if existingCategory.TeamID != category.TeamID {
|
||||
return nil, model.ErrCategoryPermissionDenied
|
||||
}
|
||||
|
||||
// in case type was defaulted above, set to existingCategory.Type
|
||||
category.Type = existingCategory.Type
|
||||
if existingCategory.Type == model.CategoryTypeSystem {
|
||||
// You cannot rename or delete a system category,
|
||||
// So restoring its name and undeleting it if set so.
|
||||
category.Name = existingCategory.Name
|
||||
category.DeleteAt = 0
|
||||
}
|
||||
|
||||
category.UpdateAt = utils.GetMillis()
|
||||
if err = category.IsValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.store.UpdateCategory(*category); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedCategory, err := a.store.GetCategory(category.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryChange(*updatedCategory)
|
||||
}()
|
||||
|
||||
return updatedCategory, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category, error) {
|
||||
existingCategory, err := a.store.GetCategory(categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// category is already deleted. This avoids
|
||||
// overriding the original deleted at timestamp
|
||||
if existingCategory.DeleteAt != 0 {
|
||||
return existingCategory, nil
|
||||
}
|
||||
|
||||
// verify if category belongs to the user
|
||||
if existingCategory.UserID != userID {
|
||||
return nil, model.ErrCategoryPermissionDenied
|
||||
}
|
||||
|
||||
// verify if category belongs to the team
|
||||
if existingCategory.TeamID != teamID {
|
||||
return nil, model.NewErrInvalidCategory("category doesn't belong to the team")
|
||||
}
|
||||
|
||||
if existingCategory.Type == model.CategoryTypeSystem {
|
||||
return nil, ErrCannotDeleteSystemCategory
|
||||
}
|
||||
|
||||
if err = a.moveBoardsToDefaultCategory(userID, teamID, categoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = a.store.DeleteCategory(categoryID, userID, teamID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deletedCategory, err := a.store.GetCategory(categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryChange(*deletedCategory)
|
||||
}()
|
||||
|
||||
return deletedCategory, nil
|
||||
}
|
||||
|
||||
func (a *App) moveBoardsToDefaultCategory(userID, teamID, sourceCategoryID string) error {
|
||||
// we need a list of boards associated to this category
|
||||
// so we can move them to user's default Boards category
|
||||
categoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sourceCategoryBoards *model.CategoryBoards
|
||||
defaultCategoryID := ""
|
||||
|
||||
// iterate user's categories to find the source category
|
||||
// and the default category.
|
||||
// We need source category to get the list of its board
|
||||
// and the default category to know its ID to
|
||||
// move source category's boards to.
|
||||
for i := range categoryBoards {
|
||||
if categoryBoards[i].ID == sourceCategoryID {
|
||||
sourceCategoryBoards = &categoryBoards[i]
|
||||
}
|
||||
|
||||
if categoryBoards[i].Name == defaultCategoryBoards {
|
||||
defaultCategoryID = categoryBoards[i].ID
|
||||
}
|
||||
|
||||
// if both categories are found, no need to iterate furthur.
|
||||
if sourceCategoryBoards != nil && defaultCategoryID != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sourceCategoryBoards == nil {
|
||||
return errCategoryNotFound
|
||||
}
|
||||
|
||||
if defaultCategoryID == "" {
|
||||
return fmt.Errorf("moveBoardsToDefaultCategory: %w", errNoDefaultCategoryFound)
|
||||
}
|
||||
|
||||
boardIDs := make([]string, len(sourceCategoryBoards.BoardMetadata))
|
||||
for i := range sourceCategoryBoards.BoardMetadata {
|
||||
boardIDs[i] = sourceCategoryBoards.BoardMetadata[i].BoardID
|
||||
}
|
||||
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil {
|
||||
return fmt.Errorf("moveBoardsToDefaultCategory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ReorderCategories(userID, teamID string, newCategoryOrder []string) ([]string, error) {
|
||||
if err := a.verifyNewCategoriesMatchExisting(userID, teamID, newCategoryOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newOrder, err := a.store.ReorderCategories(userID, teamID, newCategoryOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryReorder(teamID, userID, newOrder)
|
||||
}()
|
||||
|
||||
return newOrder, nil
|
||||
}
|
||||
|
||||
func (a *App) verifyNewCategoriesMatchExisting(userID, teamID string, newCategoryOrder []string) error {
|
||||
existingCategories, err := a.store.GetUserCategories(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(newCategoryOrder) != len(existingCategories) {
|
||||
return fmt.Errorf(
|
||||
"%w length new categories: %d, length existing categories: %d, userID: %s, teamID: %s",
|
||||
errCategoriesLengthMismatch,
|
||||
len(newCategoryOrder),
|
||||
len(existingCategories),
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
}
|
||||
|
||||
existingCategoriesMap := map[string]bool{}
|
||||
for _, category := range existingCategories {
|
||||
existingCategoriesMap[category.ID] = true
|
||||
}
|
||||
|
||||
for _, newCategoryID := range newCategoryOrder {
|
||||
if _, found := existingCategoriesMap[newCategoryID]; !found {
|
||||
return fmt.Errorf(
|
||||
"%w specified category ID: %s, userID: %s, teamID: %s",
|
||||
errCategoryNotFound,
|
||||
newCategoryID,
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
282
server/boards/app/category_boards.go
Обычный файл
282
server/boards/app/category_boards.go
Обычный файл
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
const defaultCategoryBoards = "Boards"
|
||||
|
||||
var errCategoryBoardsLengthMismatch = errors.New("cannot update category boards order, passed list of categories boards different size than in database")
|
||||
var errBoardNotFoundInCategory = errors.New("specified board ID not found in specified category ID")
|
||||
var errBoardMembershipNotFound = errors.New("board membership not found for user's board")
|
||||
|
||||
func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) {
|
||||
categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdCategoryBoards, err := a.createDefaultCategoriesIfRequired(categoryBoards, userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
categoryBoards = append(categoryBoards, createdCategoryBoards...)
|
||||
return categoryBoards, nil
|
||||
}
|
||||
|
||||
func (a *App) createDefaultCategoriesIfRequired(existingCategoryBoards []model.CategoryBoards, userID, teamID string) ([]model.CategoryBoards, error) {
|
||||
createdCategories := []model.CategoryBoards{}
|
||||
|
||||
boardsCategoryExist := false
|
||||
for _, categoryBoard := range existingCategoryBoards {
|
||||
if categoryBoard.Name == defaultCategoryBoards {
|
||||
boardsCategoryExist = true
|
||||
}
|
||||
}
|
||||
|
||||
if !boardsCategoryExist {
|
||||
createdCategoryBoards, err := a.createBoardsCategory(userID, teamID, existingCategoryBoards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdCategories = append(createdCategories, *createdCategoryBoards)
|
||||
}
|
||||
|
||||
return createdCategories, nil
|
||||
}
|
||||
|
||||
func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards []model.CategoryBoards) (*model.CategoryBoards, error) {
|
||||
// create the category
|
||||
category := model.Category{
|
||||
Name: defaultCategoryBoards,
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
Collapsed: false,
|
||||
Type: model.CategoryTypeSystem,
|
||||
SortOrder: len(existingCategoryBoards) * model.CategoryBoardsSortOrderGap,
|
||||
}
|
||||
createdCategory, err := a.CreateCategory(&category)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory default category creation failed: %w", err)
|
||||
}
|
||||
|
||||
// once the category is created, we need to move all boards which do not
|
||||
// belong to any category, into this category.
|
||||
|
||||
boardMembers, err := a.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory error fetching user's board memberships: %w", err)
|
||||
}
|
||||
|
||||
boardMemberByBoardID := map[string]*model.BoardMember{}
|
||||
for _, boardMember := range boardMembers {
|
||||
boardMemberByBoardID[boardMember.BoardID] = boardMember
|
||||
}
|
||||
|
||||
createdCategoryBoards := &model.CategoryBoards{
|
||||
Category: *createdCategory,
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
}
|
||||
|
||||
// get user's current team's baords
|
||||
userTeamBoards, err := a.GetBoardsForUserAndTeam(userID, teamID, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err)
|
||||
}
|
||||
|
||||
boardIDsToAdd := []string{}
|
||||
|
||||
for _, board := range userTeamBoards {
|
||||
boardMembership, ok := boardMemberByBoardID[board.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("createBoardsCategory: %w", errBoardMembershipNotFound)
|
||||
}
|
||||
|
||||
// boards with implicit access (aka synthetic membership),
|
||||
// should show up in LHS only when openign them explicitelly.
|
||||
// So we don't process any synthetic membership boards
|
||||
// and only add boards with explicit access to, to the the LHS,
|
||||
// for example, if a user explicitelly added another user to a board.
|
||||
if boardMembership.Synthetic {
|
||||
continue
|
||||
}
|
||||
|
||||
belongsToCategory := false
|
||||
|
||||
for _, categoryBoard := range existingCategoryBoards {
|
||||
for _, metadata := range categoryBoard.BoardMetadata {
|
||||
if metadata.BoardID == board.ID {
|
||||
belongsToCategory = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// stop looking into other categories if
|
||||
// the board was found in a category
|
||||
if belongsToCategory {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !belongsToCategory {
|
||||
boardIDsToAdd = append(boardIDsToAdd, board.ID)
|
||||
newBoardMetadata := model.CategoryBoardMetadata{
|
||||
BoardID: board.ID,
|
||||
Hidden: false,
|
||||
}
|
||||
createdCategoryBoards.BoardMetadata = append(createdCategoryBoards.BoardMetadata, newBoardMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
if len(boardIDsToAdd) > 0 {
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, boardIDsToAdd); err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return createdCategoryBoards, nil
|
||||
}
|
||||
|
||||
func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID string, boardIDs []string) error {
|
||||
if len(boardIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var updatedCategory *model.CategoryBoards
|
||||
for i := range userCategoryBoards {
|
||||
if userCategoryBoards[i].ID == categoryID {
|
||||
updatedCategory = &userCategoryBoards[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if updatedCategory == nil {
|
||||
return errCategoryNotFound
|
||||
}
|
||||
|
||||
wsPayload := make([]*model.BoardCategoryWebsocketData, len(updatedCategory.BoardMetadata))
|
||||
i := 0
|
||||
for _, categoryBoardMetadata := range updatedCategory.BoardMetadata {
|
||||
wsPayload[i] = &model.BoardCategoryWebsocketData{
|
||||
BoardID: categoryBoardMetadata.BoardID,
|
||||
CategoryID: categoryID,
|
||||
Hidden: categoryBoardMetadata.Hidden,
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastCategoryBoardChange(
|
||||
teamID,
|
||||
userID,
|
||||
wsPayload,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ReorderCategoryBoards(userID, teamID, categoryID string, newBoardsOrder []string) ([]string, error) {
|
||||
if err := a.verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID, newBoardsOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newOrder, err := a.store.ReorderCategoryBoards(categoryID, newBoardsOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryBoardsReorder(teamID, userID, categoryID, newOrder)
|
||||
}()
|
||||
|
||||
return newOrder, nil
|
||||
}
|
||||
|
||||
func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID string, newBoardsOrder []string) error {
|
||||
// this function is to ensure that we don't miss specifying
|
||||
// all boards of the category while reordering.
|
||||
existingCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var targetCategoryBoards *model.CategoryBoards
|
||||
for i := range existingCategoryBoards {
|
||||
if existingCategoryBoards[i].Category.ID == categoryID {
|
||||
targetCategoryBoards = &existingCategoryBoards[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetCategoryBoards == nil {
|
||||
return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID)
|
||||
}
|
||||
|
||||
if len(targetCategoryBoards.BoardMetadata) != len(newBoardsOrder) {
|
||||
return fmt.Errorf(
|
||||
"%w length new category boards: %d, length existing category boards: %d, userID: %s, teamID: %s, categoryID: %s",
|
||||
errCategoryBoardsLengthMismatch,
|
||||
len(newBoardsOrder),
|
||||
len(targetCategoryBoards.BoardMetadata),
|
||||
userID,
|
||||
teamID,
|
||||
categoryID,
|
||||
)
|
||||
}
|
||||
|
||||
existingBoardMap := map[string]bool{}
|
||||
for _, metadata := range targetCategoryBoards.BoardMetadata {
|
||||
existingBoardMap[metadata.BoardID] = true
|
||||
}
|
||||
|
||||
for _, boardID := range newBoardsOrder {
|
||||
if _, found := existingBoardMap[boardID]; !found {
|
||||
return fmt.Errorf(
|
||||
"%w board ID: %s, category ID: %s, userID: %s, teamID: %s",
|
||||
errBoardNotFoundInCategory,
|
||||
boardID,
|
||||
categoryID,
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SetBoardVisibility(teamID, userID, categoryID, boardID string, visible bool) error {
|
||||
if err := a.store.SetBoardVisibility(userID, categoryID, boardID, visible); err != nil {
|
||||
return fmt.Errorf("SetBoardVisibility: failed to update board visibility: %w", err)
|
||||
}
|
||||
|
||||
a.wsAdapter.BroadcastCategoryBoardChange(teamID, userID, []*model.BoardCategoryWebsocketData{
|
||||
{
|
||||
BoardID: boardID,
|
||||
CategoryID: categoryID,
|
||||
Hidden: !visible,
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
340
server/boards/app/category_boards_test.go
Обычный файл
340
server/boards/app/category_boards_test.go
Обычный файл
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestGetUserCategoryBoards(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("user had no default category and had boards", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil).Times(1)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: model.CategoryTypeSystem,
|
||||
Name: "Boards",
|
||||
},
|
||||
},
|
||||
}, nil).Times(1)
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
|
||||
board2 := &model.Board{
|
||||
ID: "board_id_2",
|
||||
}
|
||||
|
||||
board3 := &model.Board{
|
||||
ID: "board_id_3",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_2",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_3",
|
||||
Synthetic: false,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
|
||||
|
||||
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(categoryBoards))
|
||||
assert.Equal(t, "Boards", categoryBoards[0].Name)
|
||||
assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata))
|
||||
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false})
|
||||
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false})
|
||||
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false})
|
||||
})
|
||||
|
||||
t.Run("user had no default category BUT had no boards", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil)
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
|
||||
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(categoryBoards))
|
||||
assert.Equal(t, "Boards", categoryBoards[0].Name)
|
||||
assert.Equal(t, 0, len(categoryBoards[0].BoardMetadata))
|
||||
})
|
||||
|
||||
t.Run("user already had a default Boards category with boards in it", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{Name: "Boards"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1", Hidden: false},
|
||||
{BoardID: "board_id_2", Hidden: false},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(categoryBoards))
|
||||
assert.Equal(t, "Boards", categoryBoards[0].Name)
|
||||
assert.Equal(t, 2, len(categoryBoards[0].BoardMetadata))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateBoardsCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("user doesn't have any boards - implicit or explicit", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, boardsCategory)
|
||||
assert.Equal(t, "Boards", boardsCategory.Name)
|
||||
assert.Equal(t, 0, len(boardsCategory.BoardMetadata))
|
||||
})
|
||||
|
||||
t.Run("user has implicit access to some board", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
Synthetic: true,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_2",
|
||||
Synthetic: true,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_3",
|
||||
Synthetic: true,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, boardsCategory)
|
||||
assert.Equal(t, "Boards", boardsCategory.Name)
|
||||
|
||||
// there should still be no boards in the default category as
|
||||
// the user had only implicit access to boards
|
||||
assert.Equal(t, 0, len(boardsCategory.BoardMetadata))
|
||||
})
|
||||
|
||||
t.Run("user has explicit access to some board", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
board2 := &model.Board{
|
||||
ID: "board_id_2",
|
||||
}
|
||||
board3 := &model.Board{
|
||||
ID: "board_id_3",
|
||||
}
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_2",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_3",
|
||||
Synthetic: false,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
Type: model.CategoryTypeSystem,
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, boardsCategory)
|
||||
assert.Equal(t, "Boards", boardsCategory.Name)
|
||||
|
||||
// since user has explicit access to three boards,
|
||||
// they should all end up in the default category
|
||||
assert.Equal(t, 3, len(boardsCategory.BoardMetadata))
|
||||
})
|
||||
|
||||
t.Run("user has both implicit and explicit access to some board", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
Synthetic: false,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_2",
|
||||
Synthetic: true,
|
||||
},
|
||||
{
|
||||
BoardID: "board_id_3",
|
||||
Synthetic: true,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1"}).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
Type: model.CategoryTypeSystem,
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, boardsCategory)
|
||||
assert.Equal(t, "Boards", boardsCategory.Name)
|
||||
|
||||
// there was only one explicit board access,
|
||||
// and so only that one should end up in the
|
||||
// default category
|
||||
assert.Equal(t, 1, len(boardsCategory.BoardMetadata))
|
||||
})
|
||||
}
|
||||
|
||||
func TestReorderCategoryBoards(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1", Hidden: false},
|
||||
{BoardID: "board_id_2", Hidden: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_3", Hidden: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().ReorderCategoryBoards("category_id_1", []string{"board_id_2", "board_id_1"}).Return([]string{"board_id_2", "board_id_1"}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(newOrder))
|
||||
assert.Equal(t, "board_id_2", newOrder[0])
|
||||
assert.Equal(t, "board_id_1", newOrder[1])
|
||||
})
|
||||
|
||||
t.Run("not specifying all boards", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_1", Hidden: false},
|
||||
{BoardID: "board_id_2", Hidden: false},
|
||||
{BoardID: "board_id_3", Hidden: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{
|
||||
{BoardID: "board_id_3", Hidden: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, newOrder)
|
||||
})
|
||||
}
|
||||
497
server/boards/app/category_test.go
Обычный файл
497
server/boards/app/category_test.go
Обычный файл
@@ -0,0 +1,497 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func TestCreateCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
Name: "Category",
|
||||
UserID: "user_id",
|
||||
TeamID: "team_id",
|
||||
Type: "custom",
|
||||
}
|
||||
createdCategory, err := th.App.CreateCategory(category)
|
||||
assert.NotNil(t, createdCategory)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("creating invalid category", func(t *testing.T) {
|
||||
category := &model.Category{
|
||||
Name: "", // empty name shouldn't be allowed
|
||||
UserID: "user_id",
|
||||
TeamID: "team_id",
|
||||
Type: "custom",
|
||||
}
|
||||
createdCategory, err := th.App.CreateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.Name = "Name"
|
||||
category.UserID = "" // empty creator user id shouldn't be allowed
|
||||
createdCategory, err = th.App.CreateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.UserID = "user_id"
|
||||
category.TeamID = "" // empty TeamID shouldn't be allowed
|
||||
createdCategory, err = th.App.CreateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.Type = "invalid" // unknown type shouldn't be allowed
|
||||
createdCategory, err = th.App.CreateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.NotNil(t, updatedCategory)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("updating invalid category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Name",
|
||||
UserID: "user_id",
|
||||
TeamID: "team_id",
|
||||
Type: "custom",
|
||||
}
|
||||
|
||||
category.ID = ""
|
||||
createdCategory, err := th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.ID = "category_id_1"
|
||||
category.Name = ""
|
||||
createdCategory, err = th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.Name = "Name"
|
||||
category.UserID = "" // empty creator user id shouldn't be allowed
|
||||
createdCategory, err = th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.UserID = "user_id"
|
||||
category.TeamID = "" // empty TeamID shouldn't be allowed
|
||||
createdCategory, err = th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
|
||||
category.Type = "invalid" // unknown type shouldn't be allowed
|
||||
createdCategory, err = th.App.UpdateCategory(category)
|
||||
assert.Nil(t, createdCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("trying to update someone else's category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
UserID: "user_id_2",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.Nil(t, updatedCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("trying to update some other team's category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_2",
|
||||
Type: "custom",
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.Nil(t, updatedCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("should not be allowed to rename system category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "system",
|
||||
}, nil).Times(1)
|
||||
|
||||
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "system",
|
||||
Collapsed: true,
|
||||
}, nil).Times(1)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Updated Name",
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "system",
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.NotNil(t, updatedCategory)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Category", updatedCategory.Name)
|
||||
})
|
||||
|
||||
t.Run("should be allowed to collapse and expand any category type", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "system",
|
||||
Collapsed: false,
|
||||
}, nil).Times(1)
|
||||
|
||||
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "system",
|
||||
Collapsed: true,
|
||||
}, nil).Times(1)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Updated Name",
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "system",
|
||||
Collapsed: true,
|
||||
}
|
||||
updatedCategory, err := th.App.UpdateCategory(category)
|
||||
assert.NotNil(t, updatedCategory)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Category", updatedCategory.Name, "The name should have not been updated")
|
||||
assert.True(t, updatedCategory.Collapsed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().DeleteCategory("category_id_1", "user_id_1", "team_id_1").Return(nil)
|
||||
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
DeleteAt: 10000,
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_default",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "default",
|
||||
Name: "Boards",
|
||||
},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
Name: "Category 1",
|
||||
},
|
||||
BoardMetadata: []model.CategoryBoardMetadata{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
|
||||
assert.NotNil(t, deletedCategory)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("trying to delete already deleted category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 1000,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
|
||||
assert.NotNil(t, deletedCategory)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("trying to delete system category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "system",
|
||||
}, nil)
|
||||
|
||||
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
|
||||
assert.Nil(t, deletedCategory)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveBoardsToDefaultCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("When default category already exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_2",
|
||||
Name: "Custom Category 1",
|
||||
Type: "custom",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("When default category doesn't already exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_2",
|
||||
Name: "Custom Category 1",
|
||||
Type: "custom",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
|
||||
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReorderCategories(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}).
|
||||
Return([]string{"category_id_2", "category_id_3", "category_id_1"}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, len(newOrder))
|
||||
})
|
||||
|
||||
t.Run("not specifying all categories should fail", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3"})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, newOrder)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyNewCategoriesMatchExisting(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
|
||||
"category_id_2",
|
||||
"category_id_3",
|
||||
"category_id_1",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("different category counts", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
|
||||
"category_id_2",
|
||||
"category_id_3",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
19
server/boards/app/clientConfig.go
Обычный файл
19
server/boards/app/clientConfig.go
Обычный файл
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) GetClientConfig() *model.ClientConfig {
|
||||
return &model.ClientConfig{
|
||||
Telemetry: a.config.Telemetry,
|
||||
TelemetryID: a.config.TelemetryID,
|
||||
EnablePublicSharedBoards: a.config.EnablePublicSharedBoards,
|
||||
TeammateNameDisplay: a.config.TeammateNameDisplay,
|
||||
FeatureFlags: a.config.FeatureFlags,
|
||||
MaxFileSize: a.config.MaxFileSize,
|
||||
}
|
||||
}
|
||||
36
server/boards/app/clientConfig_test.go
Обычный файл
36
server/boards/app/clientConfig_test.go
Обычный файл
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
|
||||
)
|
||||
|
||||
func TestGetClientConfig(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("Test Get Client Config", func(t *testing.T) {
|
||||
newConfiguration := config.Configuration{}
|
||||
newConfiguration.Telemetry = true
|
||||
newConfiguration.TelemetryID = "abcde"
|
||||
newConfiguration.EnablePublicSharedBoards = true
|
||||
newConfiguration.FeatureFlags = make(map[string]string)
|
||||
newConfiguration.FeatureFlags["BoardsFeature1"] = "true"
|
||||
newConfiguration.FeatureFlags["BoardsFeature2"] = "true"
|
||||
newConfiguration.TeammateNameDisplay = "username"
|
||||
th.App.SetConfig(&newConfiguration)
|
||||
|
||||
clientConfig := th.App.GetClientConfig()
|
||||
require.True(t, clientConfig.EnablePublicSharedBoards)
|
||||
require.True(t, clientConfig.Telemetry)
|
||||
require.Equal(t, "abcde", clientConfig.TelemetryID)
|
||||
require.Equal(t, 2, len(clientConfig.FeatureFlags))
|
||||
require.Equal(t, "username", clientConfig.TeammateNameDisplay)
|
||||
})
|
||||
}
|
||||
334
server/boards/app/cloud.go
Обычный файл
334
server/boards/app/cloud.go
Обычный файл
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
var ErrNilPluginAPI = errors.New("server not running in plugin mode")
|
||||
|
||||
// GetBoardsCloudLimits returns the limits of the server, and an empty
|
||||
// limits struct if there are no limits set.
|
||||
func (a *App) GetBoardsCloudLimits() (*model.BoardsCloudLimits, error) {
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
/*
|
||||
if !a.IsCloud() {
|
||||
return &model.BoardsCloudLimits{}, nil
|
||||
}
|
||||
|
||||
productLimits, err := a.store.GetCloudLimits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
usedCards, err := a.store.GetUsedCardsCount()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardsCloudLimits := &model.BoardsCloudLimits{
|
||||
UsedCards: usedCards,
|
||||
CardLimitTimestamp: cardLimitTimestamp,
|
||||
}
|
||||
if productLimits != nil && productLimits.Boards != nil {
|
||||
if productLimits.Boards.Cards != nil {
|
||||
boardsCloudLimits.Cards = *productLimits.Boards.Cards
|
||||
}
|
||||
if productLimits.Boards.Views != nil {
|
||||
boardsCloudLimits.Views = *productLimits.Boards.Views
|
||||
}
|
||||
}
|
||||
|
||||
return boardsCloudLimits, nil
|
||||
*/
|
||||
|
||||
return &model.BoardsCloudLimits{}, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUsedCardsCount() (int, error) {
|
||||
return a.store.GetUsedCardsCount()
|
||||
}
|
||||
|
||||
// IsCloud returns true if the server is running as a plugin in a
|
||||
// cloud licensed server.
|
||||
func (a *App) IsCloud() bool {
|
||||
return utils.IsCloudLicense(a.store.GetLicense())
|
||||
}
|
||||
|
||||
// IsCloudLimited returns true if the server is running in cloud mode
|
||||
// and the card limit has been set.
|
||||
func (a *App) IsCloudLimited() bool {
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
|
||||
// return a.CardLimit() != 0 && a.IsCloud()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetCloudLimits sets the limits of the server.
|
||||
func (a *App) SetCloudLimits(limits *mm_model.ProductLimits) error {
|
||||
oldCardLimit := a.CardLimit()
|
||||
|
||||
// if the limit object doesn't come complete, we assume limits are
|
||||
// being disabled
|
||||
cardLimit := 0
|
||||
if limits != nil && limits.Boards != nil && limits.Boards.Cards != nil {
|
||||
cardLimit = *limits.Boards.Cards
|
||||
}
|
||||
|
||||
if oldCardLimit != cardLimit {
|
||||
a.logger.Info(
|
||||
"setting new cloud limits",
|
||||
mlog.Int("oldCardLimit", oldCardLimit),
|
||||
mlog.Int("cardLimit", cardLimit),
|
||||
)
|
||||
a.SetCardLimit(cardLimit)
|
||||
return a.doUpdateCardLimitTimestamp()
|
||||
}
|
||||
|
||||
a.logger.Info(
|
||||
"setting new cloud limits, equivalent to the existing ones",
|
||||
mlog.Int("cardLimit", cardLimit),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// doUpdateCardLimitTimestamp performs the update without running any
|
||||
// checks.
|
||||
func (a *App) doUpdateCardLimitTimestamp() error {
|
||||
cardLimitTimestamp, err := a.store.UpdateCardLimitTimestamp(a.CardLimit())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.wsAdapter.BroadcastCardLimitTimestampChange(cardLimitTimestamp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCardLimitTimestamp checks if the server is a cloud instance
|
||||
// with limits applied, and if that's true, recalculates the card
|
||||
// limit timestamp and propagates the new one to the connected
|
||||
// clients.
|
||||
func (a *App) UpdateCardLimitTimestamp() error {
|
||||
if !a.IsCloudLimited() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return a.doUpdateCardLimitTimestamp()
|
||||
}
|
||||
|
||||
// getTemplateMapForBlocks gets all board ids for the blocks, and
|
||||
// builds a map with the board IDs as the key and their isTemplate
|
||||
// field as the value.
|
||||
func (a *App) getTemplateMapForBlocks(blocks []*model.Block) (map[string]bool, error) {
|
||||
boardMap := map[string]*model.Board{}
|
||||
for _, block := range blocks {
|
||||
if _, ok := boardMap[block.BoardID]; !ok {
|
||||
board, err := a.store.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boardMap[block.BoardID] = board
|
||||
}
|
||||
}
|
||||
|
||||
templateMap := map[string]bool{}
|
||||
for boardID, board := range boardMap {
|
||||
templateMap[boardID] = board.IsTemplate
|
||||
}
|
||||
|
||||
return templateMap, nil
|
||||
}
|
||||
|
||||
// ApplyCloudLimits takes a set of blocks and, if the server is cloud
|
||||
// limited, limits those that are outside of the card limit and don't
|
||||
// belong to a template.
|
||||
func (a *App) ApplyCloudLimits(blocks []*model.Block) ([]*model.Block, error) {
|
||||
// if there is no limit currently being applied, return
|
||||
if !a.IsCloudLimited() {
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
templateMap, err := a.getTemplateMapForBlocks(blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limitedBlocks := make([]*model.Block, len(blocks))
|
||||
for i, block := range blocks {
|
||||
// if the block belongs to a template, it will never be
|
||||
// limited
|
||||
if isTemplate, ok := templateMap[block.BoardID]; ok && isTemplate {
|
||||
limitedBlocks[i] = block
|
||||
continue
|
||||
}
|
||||
|
||||
if block.ShouldBeLimited(cardLimitTimestamp) {
|
||||
limitedBlocks[i] = block.GetLimited()
|
||||
} else {
|
||||
limitedBlocks[i] = block
|
||||
}
|
||||
}
|
||||
|
||||
return limitedBlocks, nil
|
||||
}
|
||||
|
||||
// ContainsLimitedBlocks checks if a list of blocks contain any block
|
||||
// that references a limited card.
|
||||
func (a *App) ContainsLimitedBlocks(blocks []*model.Block) (bool, error) {
|
||||
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if cardLimitTimestamp == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cards := []*model.Block{}
|
||||
cardIDMap := map[string]bool{}
|
||||
for _, block := range blocks {
|
||||
switch block.Type {
|
||||
case model.TypeCard:
|
||||
cards = append(cards, block)
|
||||
default:
|
||||
cardIDMap[block.ParentID] = true
|
||||
}
|
||||
}
|
||||
|
||||
cardIDs := []string{}
|
||||
// if the card is already present on the set, we don't need to
|
||||
// fetch it from the database
|
||||
for cardID := range cardIDMap {
|
||||
alreadyPresent := false
|
||||
for _, card := range cards {
|
||||
if card.ID == cardID {
|
||||
alreadyPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !alreadyPresent {
|
||||
cardIDs = append(cardIDs, cardID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cardIDs) > 0 {
|
||||
fetchedCards, fErr := a.store.GetBlocksByIDs(cardIDs)
|
||||
if fErr != nil {
|
||||
return false, fErr
|
||||
}
|
||||
cards = append(cards, fetchedCards...)
|
||||
}
|
||||
|
||||
templateMap, err := a.getTemplateMapForBlocks(cards)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, card := range cards {
|
||||
isTemplate, ok := templateMap[card.BoardID]
|
||||
if !ok {
|
||||
return false, newErrBoardNotFoundInTemplateMap(card.BoardID)
|
||||
}
|
||||
|
||||
// if the block belongs to a template, it will never be
|
||||
// limited
|
||||
if isTemplate {
|
||||
continue
|
||||
}
|
||||
|
||||
if card.ShouldBeLimited(cardLimitTimestamp) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type errBoardNotFoundInTemplateMap struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func newErrBoardNotFoundInTemplateMap(id string) *errBoardNotFoundInTemplateMap {
|
||||
return &errBoardNotFoundInTemplateMap{id}
|
||||
}
|
||||
|
||||
func (eb *errBoardNotFoundInTemplateMap) Error() string {
|
||||
return fmt.Sprintf("board %q not found in template map", eb.id)
|
||||
}
|
||||
|
||||
func (a *App) NotifyPortalAdminsUpgradeRequest(teamID string) error {
|
||||
if a.servicesAPI == nil {
|
||||
return ErrNilPluginAPI
|
||||
}
|
||||
|
||||
team, err := a.store.GetTeam(teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ofWhat string
|
||||
if team == nil {
|
||||
ofWhat = "your organization"
|
||||
} else {
|
||||
ofWhat = team.Title
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("A member of %s has notified you to upgrade this workspace before the trial ends.", ofWhat)
|
||||
|
||||
page := 0
|
||||
getUsersOptions := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: page,
|
||||
}
|
||||
|
||||
for ; true; page++ {
|
||||
getUsersOptions.Page = page
|
||||
systemAdmins, appErr := a.servicesAPI.GetUsersFromProfiles(getUsersOptions)
|
||||
if appErr != nil {
|
||||
a.logger.Error("failed to fetch system admins", mlog.Int("page_size", getUsersOptions.PerPage), mlog.Int("page", page), mlog.Err(appErr))
|
||||
return appErr
|
||||
}
|
||||
|
||||
if len(systemAdmins) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
receiptUserIDs := []string{}
|
||||
for _, systemAdmin := range systemAdmins {
|
||||
receiptUserIDs = append(receiptUserIDs, systemAdmin.Id)
|
||||
}
|
||||
|
||||
if err := a.store.SendMessage(message, "custom_cloud_upgrade_nudge", receiptUserIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
780
server/boards/app/cloud_test.go
Обычный файл
780
server/boards/app/cloud_test.go
Обычный файл
@@ -0,0 +1,780 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
mockservicesapi "github.com/mattermost/mattermost-server/v6/server/boards/model/mocks"
|
||||
)
|
||||
|
||||
func TestIsCloud(t *testing.T) {
|
||||
t.Run("if it's not running on plugin mode", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(nil)
|
||||
require.False(t, th.App.IsCloud())
|
||||
})
|
||||
|
||||
t.Run("if it's running on plugin mode but the license is incomplete", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{}
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
require.False(t, th.App.IsCloud())
|
||||
|
||||
fakeLicense = &mm_model.License{Features: &mm_model.Features{}}
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
require.False(t, th.App.IsCloud())
|
||||
})
|
||||
|
||||
t.Run("if it's running on plugin mode, with a non-cloud license", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(false)},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
require.False(t, th.App.IsCloud())
|
||||
})
|
||||
|
||||
t.Run("if it's running on plugin mode with a cloud license", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
require.True(t, th.App.IsCloud())
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsCloudLimited(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("if no limit has been set, it should be false", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
require.False(t, th.App.IsCloudLimited())
|
||||
})
|
||||
|
||||
t.Run("if the limit is set, it should be true", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
require.True(t, th.App.IsCloudLimited())
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetCloudLimits(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("if the limits are empty, it should do nothing", func(t *testing.T) {
|
||||
t.Run("limits empty", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(nil))
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
})
|
||||
|
||||
t.Run("limits not empty but board limits empty", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
limits := &mm_model.ProductLimits{}
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(limits))
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
})
|
||||
|
||||
t.Run("limits not empty but board limits values empty", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
limits := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{},
|
||||
}
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(limits))
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("if the limits are not empty, it should update them and calculate the new timestamp", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
newCardLimitTimestamp := int64(27)
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(5).Return(newCardLimitTimestamp, nil)
|
||||
|
||||
limits := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{Cards: mm_model.NewInt(5)},
|
||||
}
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(limits))
|
||||
require.Equal(t, 5, th.App.CardLimit())
|
||||
})
|
||||
|
||||
t.Run("if the limits are already set and we unset them, the timestamp will be unset too", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.App.SetCardLimit(20)
|
||||
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(0)
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(nil))
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
})
|
||||
|
||||
t.Run("if the limits are already set and we try to set the same ones again", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.App.SetCardLimit(20)
|
||||
|
||||
// the call to update card limit timestamp should not happen
|
||||
// as the limits didn't change
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0)
|
||||
|
||||
limits := &mm_model.ProductLimits{
|
||||
Boards: &mm_model.BoardsLimits{Cards: mm_model.NewInt(20)},
|
||||
}
|
||||
|
||||
require.NoError(t, th.App.SetCloudLimits(limits))
|
||||
require.Equal(t, 20, th.App.CardLimit())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateCardLimitTimestamp(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
t.Run("if the server is a cloud instance but not limited, it should do nothing", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
// the license check will not be done as the limit not being
|
||||
// set is enough for the method to return
|
||||
th.Store.EXPECT().GetLicense().Times(0)
|
||||
// no call to UpdateCardLimitTimestamp should happen as the
|
||||
// method should shortcircuit if not cloud limited
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0)
|
||||
|
||||
require.NoError(t, th.App.UpdateCardLimitTimestamp())
|
||||
})
|
||||
|
||||
t.Run("if the server is a cloud instance and the timestamp is set, it should run the update", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
// no call to UpdateCardLimitTimestamp should happen as the
|
||||
// method should shortcircuit if not cloud limited
|
||||
th.Store.EXPECT().UpdateCardLimitTimestamp(5)
|
||||
|
||||
require.NoError(t, th.App.UpdateCardLimitTimestamp())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTemplateMapForBlocks(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("should fetch the necessary boards from the database", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
board2 := &model.Board{
|
||||
ID: "board2",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: false,
|
||||
}
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
},
|
||||
{
|
||||
ID: "card2",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board2",
|
||||
BoardID: "board2",
|
||||
},
|
||||
{
|
||||
ID: "text2",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card2",
|
||||
BoardID: "board2",
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().
|
||||
GetBoard("board1").
|
||||
Return(board1, nil).
|
||||
Times(1)
|
||||
th.Store.EXPECT().
|
||||
GetBoard("board2").
|
||||
Return(board2, nil).
|
||||
Times(1)
|
||||
|
||||
templateMap, err := th.App.getTemplateMapForBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, templateMap, 2)
|
||||
require.Contains(t, templateMap, "board1")
|
||||
require.True(t, templateMap["board1"])
|
||||
require.Contains(t, templateMap, "board2")
|
||||
require.False(t, templateMap["board2"])
|
||||
})
|
||||
|
||||
t.Run("should fail if the board is not in the database", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
},
|
||||
{
|
||||
ID: "card2",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board2",
|
||||
BoardID: "board2",
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().
|
||||
GetBoard("board1").
|
||||
Return(nil, sql.ErrNoRows).
|
||||
Times(1)
|
||||
|
||||
templateMap, err := th.App.getTemplateMapForBlocks(blocks)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
require.Empty(t, templateMap)
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyCloudLimits(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
fakeLicense := &mm_model.License{
|
||||
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: false,
|
||||
}
|
||||
|
||||
template := &model.Board{
|
||||
ID: "template",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
{
|
||||
ID: "text1",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
{
|
||||
ID: "card2",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
},
|
||||
{
|
||||
ID: "card-from-template",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "template",
|
||||
BoardID: "template",
|
||||
UpdateAt: 1,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("if the server is not limited, it should return the blocks untouched", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
require.Zero(t, th.App.CardLimit())
|
||||
|
||||
newBlocks, err := th.App.ApplyCloudLimits(blocks)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, blocks, newBlocks)
|
||||
})
|
||||
|
||||
t.Run("if the server is limited, it should limit the blocks that are beyond the card limit timestamp", func(t *testing.T) {
|
||||
findBlock := func(blocks []*model.Block, id string) *model.Block {
|
||||
for _, block := range blocks {
|
||||
if block.ID == id {
|
||||
return block
|
||||
}
|
||||
}
|
||||
require.FailNow(t, "block %s not found", id)
|
||||
return &model.Block{} // this should never be reached
|
||||
}
|
||||
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil).Times(1)
|
||||
th.Store.EXPECT().GetBoard("template").Return(template, nil).Times(1)
|
||||
|
||||
newBlocks, err := th.App.ApplyCloudLimits(blocks)
|
||||
require.NoError(t, err)
|
||||
|
||||
// should be limited as it's beyond the threshold
|
||||
require.True(t, findBlock(newBlocks, "card1").Limited)
|
||||
// only cards are limited
|
||||
require.False(t, findBlock(newBlocks, "text1").Limited)
|
||||
// should not be limited as it's not beyond the threshold
|
||||
require.False(t, findBlock(newBlocks, "card2").Limited)
|
||||
// cards belonging to templates are never limited
|
||||
require.False(t, findBlock(newBlocks, "card-from-template").Limited)
|
||||
})
|
||||
}
|
||||
|
||||
func TestContainsLimitedBlocks(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
// for all the following tests, the timestamp will be set to 150,
|
||||
// which means that blocks with an UpdateAt set to 100 will be
|
||||
// outside the active window and possibly limited, and blocks with
|
||||
// UpdateAt set to 200 will not
|
||||
|
||||
t.Run("should return false if the card limit timestamp is zero", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(0), nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should return true if the block set contains a card that is limited", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypePrivate,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.True(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should return false if that same block belongs to a template", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should return true if the block contains a content block that belongs to a card that should be limited", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "text1",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
},
|
||||
}
|
||||
|
||||
card1 := &model.Block{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]*model.Block{card1}, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.True(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should return false if that same block belongs to a card that is inside the active window", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
{
|
||||
ID: "text1",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
},
|
||||
}
|
||||
|
||||
card1 := &model.Block{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]*model.Block{card1}, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
|
||||
t.Run("should reach to the database to fetch the necessary information only in an efficient way", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks := []*model.Block{
|
||||
// a content block that references a card that needs
|
||||
// fetching
|
||||
{
|
||||
ID: "text1",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
// a board that needs fetching referenced by a card and a content block
|
||||
{
|
||||
ID: "card2",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board2",
|
||||
BoardID: "board2",
|
||||
// per timestamp should be limited but the board is a
|
||||
// template
|
||||
UpdateAt: 100,
|
||||
},
|
||||
{
|
||||
ID: "text2",
|
||||
Type: model.TypeText,
|
||||
ParentID: "card2",
|
||||
BoardID: "board2",
|
||||
UpdateAt: 200,
|
||||
},
|
||||
// a content block that references a card and a board,
|
||||
// both absent
|
||||
{
|
||||
ID: "image3",
|
||||
Type: model.TypeImage,
|
||||
ParentID: "card3",
|
||||
BoardID: "board3",
|
||||
UpdateAt: 100,
|
||||
},
|
||||
}
|
||||
|
||||
card1 := &model.Block{
|
||||
ID: "card1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board1",
|
||||
BoardID: "board1",
|
||||
UpdateAt: 200,
|
||||
}
|
||||
|
||||
card3 := &model.Block{
|
||||
ID: "card3",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board3",
|
||||
BoardID: "board3",
|
||||
UpdateAt: 200,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board1",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
board2 := &model.Board{
|
||||
ID: "board2",
|
||||
Type: model.BoardTypeOpen,
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
board3 := &model.Board{
|
||||
ID: "board3",
|
||||
Type: model.BoardTypePrivate,
|
||||
}
|
||||
|
||||
th.App.SetCardLimit(500)
|
||||
cardLimitTimestamp := int64(150)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
|
||||
th.Store.EXPECT().GetBlocksByIDs(gomock.InAnyOrder([]string{"card1", "card3"})).Return([]*model.Block{card1, card3}, nil)
|
||||
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
|
||||
th.Store.EXPECT().GetBoard("board2").Return(board2, nil)
|
||||
th.Store.EXPECT().GetBoard("board3").Return(board3, nil)
|
||||
|
||||
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
|
||||
require.NoError(t, err)
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifyPortalAdminsUpgradeRequest(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("should send message", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
|
||||
|
||||
sysAdmin1 := &mm_model.User{
|
||||
Id: "michael-scott",
|
||||
Username: "Michael Scott",
|
||||
}
|
||||
|
||||
sysAdmin2 := &mm_model.User{
|
||||
Id: "dwight-schrute",
|
||||
Username: "Dwight Schrute",
|
||||
}
|
||||
|
||||
getUsersOptionsPage0 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{sysAdmin1, sysAdmin2}, nil)
|
||||
|
||||
getUsersOptionsPage1 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 1,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage1).Return([]*mm_model.User{}, nil)
|
||||
|
||||
th.App.servicesAPI = servicesAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
th.Store.EXPECT().SendMessage(gomock.Any(), "custom_cloud_upgrade_nudge", gomock.Any()).Return(nil).Times(1)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("no sys admins found", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
|
||||
|
||||
getUsersOptionsPage0 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{}, nil)
|
||||
|
||||
th.App.servicesAPI = servicesAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("iterate multiple pages", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
|
||||
|
||||
sysAdmin1 := &mm_model.User{
|
||||
Id: "michael-scott",
|
||||
Username: "Michael Scott",
|
||||
}
|
||||
|
||||
sysAdmin2 := &mm_model.User{
|
||||
Id: "dwight-schrute",
|
||||
Username: "Dwight Schrute",
|
||||
}
|
||||
|
||||
getUsersOptionsPage0 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{sysAdmin1}, nil)
|
||||
|
||||
getUsersOptionsPage1 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 1,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage1).Return([]*mm_model.User{sysAdmin2}, nil)
|
||||
|
||||
getUsersOptionsPage2 := &mm_model.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mm_model.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 2,
|
||||
}
|
||||
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage2).Return([]*mm_model.User{}, nil)
|
||||
|
||||
th.App.servicesAPI = servicesAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
th.Store.EXPECT().SendMessage(gomock.Any(), "custom_cloud_upgrade_nudge", gomock.Any()).Return(nil).Times(2)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
18
server/boards/app/compliance.go
Обычный файл
18
server/boards/app/compliance.go
Обычный файл
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
func (a *App) GetBoardsForCompliance(opts model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) {
|
||||
return a.store.GetBoardsForCompliance(opts)
|
||||
}
|
||||
|
||||
func (a *App) GetBoardsComplianceHistory(opts model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) {
|
||||
return a.store.GetBoardsComplianceHistory(opts)
|
||||
}
|
||||
|
||||
func (a *App) GetBlocksComplianceHistory(opts model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) {
|
||||
return a.store.GetBlocksComplianceHistory(opts)
|
||||
}
|
||||
85
server/boards/app/content_blocks.go
Обычный файл
85
server/boards/app/content_blocks.go
Обычный файл
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) MoveContentBlock(block *model.Block, dstBlock *model.Block, where string, userID string) error {
|
||||
if block.ParentID != dstBlock.ParentID {
|
||||
message := fmt.Sprintf("not matching parent %s and %s", block.ParentID, dstBlock.ParentID)
|
||||
return model.NewErrBadRequest(message)
|
||||
}
|
||||
|
||||
card, err := a.GetBlockByID(block.ParentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentOrderData, ok := card.Fields["contentOrder"]
|
||||
var contentOrder []interface{}
|
||||
if ok {
|
||||
contentOrder = contentOrderData.([]interface{})
|
||||
}
|
||||
|
||||
newContentOrder := []interface{}{}
|
||||
foundDst := false
|
||||
foundSrc := false
|
||||
for _, id := range contentOrder {
|
||||
stringID, ok := id.(string)
|
||||
if !ok {
|
||||
newContentOrder = append(newContentOrder, id)
|
||||
continue
|
||||
}
|
||||
|
||||
if dstBlock.ID == stringID {
|
||||
foundDst = true
|
||||
if where == "after" {
|
||||
newContentOrder = append(newContentOrder, id)
|
||||
newContentOrder = append(newContentOrder, block.ID)
|
||||
} else {
|
||||
newContentOrder = append(newContentOrder, block.ID)
|
||||
newContentOrder = append(newContentOrder, id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if block.ID == stringID {
|
||||
foundSrc = true
|
||||
continue
|
||||
}
|
||||
|
||||
newContentOrder = append(newContentOrder, id)
|
||||
}
|
||||
|
||||
if !foundSrc {
|
||||
message := fmt.Sprintf("source block %s not found", block.ID)
|
||||
return model.NewErrBadRequest(message)
|
||||
}
|
||||
|
||||
if !foundDst {
|
||||
message := fmt.Sprintf("destination block %s not found", dstBlock.ID)
|
||||
return model.NewErrBadRequest(message)
|
||||
}
|
||||
|
||||
patch := &model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{
|
||||
"contentOrder": newContentOrder,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = a.PatchBlock(block.ParentID, patch, userID)
|
||||
if errors.Is(err, model.ErrPatchUpdatesLimitedCards) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
194
server/boards/app/content_blocks_test.go
Обычный файл
194
server/boards/app/content_blocks_test.go
Обычный файл
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type contentOrderMatcher struct {
|
||||
contentOrder []string
|
||||
}
|
||||
|
||||
func NewContentOrderMatcher(contentOrder []string) contentOrderMatcher {
|
||||
return contentOrderMatcher{contentOrder}
|
||||
}
|
||||
|
||||
func (com contentOrderMatcher) Matches(x interface{}) bool {
|
||||
patch, ok := x.(*model.BlockPatch)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
contentOrderData, ok := patch.UpdatedFields["contentOrder"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
contentOrder, ok := contentOrderData.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(contentOrder) != len(com.contentOrder) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range contentOrder {
|
||||
if contentOrder[i] != com.contentOrder[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (com contentOrderMatcher) String() string {
|
||||
return fmt.Sprint(&model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": com.contentOrder}})
|
||||
}
|
||||
|
||||
func TestMoveContentBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
ttCases := []struct {
|
||||
name string
|
||||
srcBlock model.Block
|
||||
dstBlock model.Block
|
||||
parentBlock *model.Block
|
||||
where string
|
||||
userID string
|
||||
mockPatch bool
|
||||
mockPatchError error
|
||||
errorMessage string
|
||||
expectedContentOrder []string
|
||||
}{
|
||||
{
|
||||
name: "not matching parents",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "other-test-card"},
|
||||
parentBlock: nil,
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "not matching parent test-card and other-test-card",
|
||||
},
|
||||
{
|
||||
name: "parent not found",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "invalid-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "invalid-card"},
|
||||
parentBlock: &model.Block{ID: "invalid-card"},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "{test} not found",
|
||||
},
|
||||
{
|
||||
name: "valid parent without content order",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card"},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "source block test-1 not found",
|
||||
},
|
||||
{
|
||||
name: "valid parent with content order but without test-1 in it",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-2"}}},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "source block test-1 not found",
|
||||
},
|
||||
{
|
||||
name: "valid parent with content order but without test-2 in it",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1"}}},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
errorMessage: "destination block test-2 not found",
|
||||
},
|
||||
{
|
||||
name: "valid request but fail on patchparent with content order",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2"}}},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
mockPatch: true,
|
||||
mockPatchError: errors.New("test error"),
|
||||
errorMessage: "test error",
|
||||
},
|
||||
{
|
||||
name: "valid request with not real change",
|
||||
srcBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
mockPatch: true,
|
||||
errorMessage: "",
|
||||
expectedContentOrder: []string{"test-1", "test-2", "test-3"},
|
||||
},
|
||||
{
|
||||
name: "valid request changing order with before",
|
||||
srcBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
|
||||
where: "before",
|
||||
userID: "user-id",
|
||||
mockPatch: true,
|
||||
errorMessage: "",
|
||||
expectedContentOrder: []string{"test-2", "test-1", "test-3"},
|
||||
},
|
||||
{
|
||||
name: "valid request changing order with after",
|
||||
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
|
||||
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
|
||||
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
|
||||
where: "after",
|
||||
userID: "user-id",
|
||||
mockPatch: true,
|
||||
errorMessage: "",
|
||||
expectedContentOrder: []string{"test-2", "test-1", "test-3"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range ttCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.parentBlock != nil {
|
||||
if tc.parentBlock.ID == "invalid-card" {
|
||||
th.Store.EXPECT().GetBlock(tc.srcBlock.ParentID).Return(nil, model.NewErrNotFound("test"))
|
||||
} else {
|
||||
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
|
||||
if tc.mockPatch {
|
||||
if tc.mockPatchError != nil {
|
||||
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(nil, tc.mockPatchError)
|
||||
} else {
|
||||
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
|
||||
th.Store.EXPECT().PatchBlock(tc.parentBlock.ID, NewContentOrderMatcher(tc.expectedContentOrder), gomock.Eq("user-id")).Return(nil)
|
||||
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
|
||||
th.Store.EXPECT().GetBoard(tc.parentBlock.BoardID).Return(&model.Board{ID: "test-board"}, nil)
|
||||
// this call comes from the WS server notification
|
||||
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := th.App.MoveContentBlock(&tc.srcBlock, &tc.dstBlock, tc.where, tc.userID)
|
||||
if tc.errorMessage == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.EqualError(t, err, tc.errorMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
253
server/boards/app/export.go
Обычный файл
253
server/boards/app/export.go
Обычный файл
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
newline = []byte{'\n'}
|
||||
)
|
||||
|
||||
func (a *App) ExportArchive(w io.Writer, opt model.ExportArchiveOptions) (errs error) {
|
||||
boards, err := a.getBoardsForArchive(opt.BoardIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
defer func() {
|
||||
errs = merr.ErrorOrNil()
|
||||
}()
|
||||
|
||||
// wrap the writer in a zip.
|
||||
zw := zip.NewWriter(w)
|
||||
defer func() {
|
||||
merr.Append(zw.Close())
|
||||
}()
|
||||
|
||||
if err := a.writeArchiveVersion(zw); err != nil {
|
||||
merr.Append(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, board := range boards {
|
||||
if err := a.writeArchiveBoard(zw, board, opt); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot export board %s: %w", board.ID, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeArchiveVersion writes a version file to the zip.
|
||||
func (a *App) writeArchiveVersion(zw *zip.Writer) error {
|
||||
archiveHeader := model.ArchiveHeader{
|
||||
Version: archiveVersion,
|
||||
Date: model.GetMillis(),
|
||||
}
|
||||
b, _ := json.Marshal(&archiveHeader)
|
||||
|
||||
w, err := zw.Create("version.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write archive header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(b); err != nil {
|
||||
return fmt.Errorf("cannot write archive header: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeArchiveBoard writes a single board to the archive in a zip directory.
|
||||
func (a *App) writeArchiveBoard(zw *zip.Writer, board model.Board, opt model.ExportArchiveOptions) error {
|
||||
// create a directory per board
|
||||
w, err := zw.Create(board.ID + "/board.jsonl")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write the board block first
|
||||
if err = a.writeArchiveBoardLine(w, board); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var files []string
|
||||
// write the board's blocks
|
||||
// TODO: paginate this
|
||||
blocks, err := a.GetBlocksForBoard(board.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, block := range blocks {
|
||||
if err = a.writeArchiveBlockLine(w, block); err != nil {
|
||||
return err
|
||||
}
|
||||
if block.Type == model.TypeImage {
|
||||
filename, err2 := extractImageFilename(block)
|
||||
if err2 != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, filename)
|
||||
}
|
||||
}
|
||||
|
||||
boardMembers, err := a.GetMembersForBoard(board.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, boardMember := range boardMembers {
|
||||
if err = a.writeArchiveBoardMemberLine(w, boardMember); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// write the files
|
||||
for _, filename := range files {
|
||||
if err := a.writeArchiveFile(zw, filename, board.ID, opt); err != nil {
|
||||
return fmt.Errorf("cannot write file %s to archive: %w", filename, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeArchiveBoardMemberLine writes a single boardMember to the archive.
|
||||
func (a *App) writeArchiveBoardMemberLine(w io.Writer, boardMember *model.BoardMember) error {
|
||||
bm, err := json.Marshal(&boardMember)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line := model.ArchiveLine{
|
||||
Type: "boardMember",
|
||||
Data: bm,
|
||||
}
|
||||
|
||||
bm, err = json.Marshal(&line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(bm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(newline)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeArchiveBlockLine writes a single block to the archive.
|
||||
func (a *App) writeArchiveBlockLine(w io.Writer, block *model.Block) error {
|
||||
b, err := json.Marshal(&block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line := model.ArchiveLine{
|
||||
Type: "block",
|
||||
Data: b,
|
||||
}
|
||||
|
||||
b, err = json.Marshal(&line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// jsonl files need a newline
|
||||
_, err = w.Write(newline)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeArchiveBlockLine writes a single block to the archive.
|
||||
func (a *App) writeArchiveBoardLine(w io.Writer, board model.Board) error {
|
||||
b, err := json.Marshal(&board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line := model.ArchiveLine{
|
||||
Type: "board",
|
||||
Data: b,
|
||||
}
|
||||
|
||||
b, err = json.Marshal(&line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// jsonl files need a newline
|
||||
_, err = w.Write(newline)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeArchiveFile writes a single file to the archive.
|
||||
func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string, opt model.ExportArchiveOptions) error {
|
||||
dest, err := zw.Create(boardID + "/" + filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src, err := a.GetFileReader(opt.TeamID, boardID, filename)
|
||||
if err != nil {
|
||||
// just log this; image file is missing but we'll still export an equivalent board
|
||||
a.logger.Error("image file missing for export",
|
||||
mlog.String("filename", filename),
|
||||
mlog.String("team_id", opt.TeamID),
|
||||
mlog.String("board_id", boardID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
_, err = io.Copy(dest, src)
|
||||
return err
|
||||
}
|
||||
|
||||
// getBoardsForArchive fetches all the specified boards.
|
||||
func (a *App) getBoardsForArchive(boardIDs []string) ([]model.Board, error) {
|
||||
boards := make([]model.Board, 0, len(boardIDs))
|
||||
|
||||
for _, id := range boardIDs {
|
||||
b, err := a.GetBoard(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not fetch board %s: %w", id, err)
|
||||
}
|
||||
|
||||
boards = append(boards, *b)
|
||||
}
|
||||
return boards, nil
|
||||
}
|
||||
|
||||
func extractImageFilename(imageBlock *model.Block) (string, error) {
|
||||
f, ok := imageBlock.Fields["fileId"]
|
||||
if !ok {
|
||||
return "", model.ErrInvalidImageBlock
|
||||
}
|
||||
|
||||
filename, ok := f.(string)
|
||||
if !ok {
|
||||
return "", model.ErrInvalidImageBlock
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
177
server/boards/app/files.go
Обычный файл
177
server/boards/app/files.go
Обычный файл
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const emptyString = "empty"
|
||||
|
||||
var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed")
|
||||
var ErrFileNotFound = errors.New("file not found")
|
||||
|
||||
func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (string, error) {
|
||||
// NOTE: File extension includes the dot
|
||||
fileExtension := strings.ToLower(filepath.Ext(filename))
|
||||
if fileExtension == ".jpeg" {
|
||||
fileExtension = ".jpg"
|
||||
}
|
||||
|
||||
createdFilename := utils.NewID(utils.IDTypeNone)
|
||||
fullFilename := fmt.Sprintf(`%s%s`, createdFilename, fileExtension)
|
||||
filePath := filepath.Join(utils.GetBaseFilePath(), fullFilename)
|
||||
|
||||
fileSize, appErr := a.filesBackend.WriteFile(reader, filePath)
|
||||
if appErr != nil {
|
||||
return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr)
|
||||
}
|
||||
|
||||
now := utils.GetMillis()
|
||||
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: createdFilename[1:],
|
||||
CreatorId: "boards",
|
||||
PostId: emptyString,
|
||||
ChannelId: emptyString,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
DeleteAt: 0,
|
||||
Path: filePath,
|
||||
ThumbnailPath: emptyString,
|
||||
PreviewPath: emptyString,
|
||||
Name: filename,
|
||||
Extension: fileExtension,
|
||||
Size: fileSize,
|
||||
MimeType: emptyString,
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
HasPreviewImage: false,
|
||||
MiniPreview: nil,
|
||||
Content: "",
|
||||
RemoteId: nil,
|
||||
}
|
||||
err := a.store.SaveFileInfo(fileInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fullFilename, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
|
||||
if filename == "" {
|
||||
return nil, errEmptyFilename
|
||||
}
|
||||
|
||||
// filename is in the format 7<some-alphanumeric-string>.<extension>
|
||||
// we want to extract the <some-alphanumeric-string> part of this as this
|
||||
// will be the fileinfo id.
|
||||
parts := strings.Split(filename, ".")
|
||||
fileInfoID := parts[0][1:]
|
||||
fileInfo, err := a.store.GetFileInfo(fileInfoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.FileInfo, filestore.ReadCloseSeeker, error) {
|
||||
fileInfo, err := a.GetFileInfo(fileName)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
a.logger.Error("111")
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var filePath string
|
||||
|
||||
if fileInfo != nil && fileInfo.Path != "" {
|
||||
filePath = fileInfo.Path
|
||||
} else {
|
||||
filePath = filepath.Join(teamID, rootID, fileName)
|
||||
}
|
||||
|
||||
exists, err := a.filesBackend.FileExists(filePath)
|
||||
if err != nil {
|
||||
a.logger.Error(fmt.Sprintf("GetFile: Failed to check if file exists as path. Path: %s, error: %e", filePath, err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, nil, ErrFileNotFound
|
||||
}
|
||||
|
||||
reader, err := a.filesBackend.Reader(filePath)
|
||||
if err != nil {
|
||||
a.logger.Error(fmt.Sprintf("GetFile: Failed to get file reader of existing file at path: %s, error: %e", filePath, err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return fileInfo, reader, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) {
|
||||
filePath := filepath.Join(teamID, rootID, filename)
|
||||
exists, err := a.filesBackend.FileExists(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// FIXUP: Check the deprecated old location
|
||||
if teamID == "0" && !exists {
|
||||
oldExists, err2 := a.filesBackend.FileExists(filename)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
if oldExists {
|
||||
err2 := a.filesBackend.MoveFile(filename, filePath)
|
||||
if err2 != nil {
|
||||
a.logger.Error("ERROR moving file",
|
||||
mlog.String("old", filename),
|
||||
mlog.String("new", filePath),
|
||||
mlog.Err(err2),
|
||||
)
|
||||
} else {
|
||||
a.logger.Debug("Moved file",
|
||||
mlog.String("old", filename),
|
||||
mlog.String("new", filePath),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if !exists {
|
||||
return nil, ErrFileNotFound
|
||||
}
|
||||
|
||||
reader, err := a.filesBackend.Reader(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
func (a *App) MoveFile(channelID, teamID, boardID, filename string) error {
|
||||
oldPath := filepath.Join(channelID, boardID, filename)
|
||||
newPath := filepath.Join(teamID, boardID, filename)
|
||||
err := a.filesBackend.MoveFile(oldPath, newPath)
|
||||
if err != nil {
|
||||
a.logger.Error("ERROR moving file",
|
||||
mlog.String("old", oldPath),
|
||||
mlog.String("new", newPath),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
387
server/boards/app/files_test.go
Обычный файл
387
server/boards/app/files_test.go
Обычный файл
@@ -0,0 +1,387 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore/mocks"
|
||||
)
|
||||
|
||||
const (
|
||||
testFileName = "temp-file-name"
|
||||
testBoardID = "test-board-id"
|
||||
)
|
||||
|
||||
var errDummy = errors.New("hello")
|
||||
|
||||
type TestError struct{}
|
||||
|
||||
func (err *TestError) Error() string { return "Mocked File backend error" }
|
||||
|
||||
func TestGetFileReader(t *testing.T) {
|
||||
testFilePath := filepath.Join("1", "test-board-id", "temp-file-name")
|
||||
|
||||
th, _ := SetupTestHelper(t)
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
t.Run("should get file reader from filestore successfully", func(t *testing.T) {
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
actual, _ := th.App.GetFileReader("1", testBoardID, testFileName)
|
||||
assert.Equal(t, mockedReadCloseSeek, actual)
|
||||
})
|
||||
|
||||
t.Run("should get error from filestore when file exists return error", func(t *testing.T) {
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedError := &TestError{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return mockedError
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
actual, err := th.App.GetFileReader("1", testBoardID, testFileName)
|
||||
assert.Error(t, err, mockedError)
|
||||
assert.Nil(t, actual)
|
||||
})
|
||||
|
||||
t.Run("should return error, if get reader from file backend returns error", func(t *testing.T) {
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedError := &TestError{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return nil
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return mockedError
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
actual, err := th.App.GetFileReader("1", testBoardID, testFileName)
|
||||
assert.Error(t, err, mockedError)
|
||||
assert.Nil(t, actual)
|
||||
})
|
||||
|
||||
t.Run("should move file from old filepath to new filepath, if file doesnot exists in new filepath and workspace id is 0", func(t *testing.T) {
|
||||
filePath := filepath.Join("0", "test-board-id", "temp-file-name")
|
||||
workspaceid := "0"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
// return true for old path
|
||||
return path == testFileName
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
moveFileFunc := func(oldFileName, newFileName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
mockedFileBackend.On("MoveFile", testFileName, filePath).Return(moveFileFunc)
|
||||
mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc)
|
||||
|
||||
actual, _ := th.App.GetFileReader(workspaceid, testBoardID, testFileName)
|
||||
assert.Equal(t, mockedReadCloseSeek, actual)
|
||||
})
|
||||
|
||||
t.Run("should return file reader, if file doesnot exists in new filepath and old file path", func(t *testing.T) {
|
||||
filePath := filepath.Join("0", "test-board-id", "temp-file-name")
|
||||
fileName := testFileName
|
||||
workspaceid := "0"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileExistsFunc := func(path string) bool {
|
||||
// return true for old path
|
||||
return false
|
||||
}
|
||||
|
||||
fileExistsErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
moveFileFunc := func(oldFileName, newFileName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc)
|
||||
mockedFileBackend.On("MoveFile", fileName, filePath).Return(moveFileFunc)
|
||||
mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc)
|
||||
|
||||
actual, _ := th.App.GetFileReader(workspaceid, testBoardID, testFileName)
|
||||
assert.Equal(t, mockedReadCloseSeek, actual)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveFile(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
t.Run("should save file to file store using file backend", func(t *testing.T) {
|
||||
fileName := "temp-file-name.txt"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
assert.Equal(t, "boards", paths[0])
|
||||
assert.Equal(t, time.Now().Format("20060102"), paths[1])
|
||||
fileName = paths[2]
|
||||
return int64(10)
|
||||
}
|
||||
|
||||
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testBoardID, fileName)
|
||||
assert.Equal(t, fileName, actual)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should save .jpeg file as jpg file to file store using file backend", func(t *testing.T) {
|
||||
fileName := "temp-file-name.jpeg"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
assert.Equal(t, "boards", paths[0])
|
||||
assert.Equal(t, time.Now().Format("20060102"), paths[1])
|
||||
assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1])
|
||||
return int64(10)
|
||||
}
|
||||
|
||||
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, actual)
|
||||
})
|
||||
|
||||
t.Run("should return error when fileBackend.WriteFile returns error", func(t *testing.T) {
|
||||
fileName := "temp-file-name.jpeg"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedError := &TestError{}
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
assert.Equal(t, "boards", paths[0])
|
||||
assert.Equal(t, time.Now().Format("20060102"), paths[1])
|
||||
assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1])
|
||||
return int64(10)
|
||||
}
|
||||
|
||||
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
|
||||
return mockedError
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName)
|
||||
assert.Equal(t, "", actual)
|
||||
assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFileInfo(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
|
||||
t.Run("should return file info", func(t *testing.T) {
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: "file_info_id",
|
||||
Archived: false,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil).Times(2)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.False(t, fetchedFileInfo.Archived)
|
||||
|
||||
fetchedFileInfo, err = th.App.GetFileInfo("Afilename.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.False(t, fetchedFileInfo.Archived)
|
||||
})
|
||||
|
||||
t.Run("should return archived file info", func(t *testing.T) {
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: "file_info_id",
|
||||
Archived: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.True(t, fetchedFileInfo.Archived)
|
||||
})
|
||||
|
||||
t.Run("should return archived file infoerror", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(nil, errDummy)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, fetchedFileInfo)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFile(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
|
||||
t.Run("when FileInfo exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
|
||||
Id: "fileInfoID",
|
||||
Path: "/path/to/file/fileName.txt",
|
||||
}, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
mockedFileBackend.On("Reader", "/path/to/file/fileName.txt").Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(true, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, fileInfo)
|
||||
assert.NotNil(t, seeker)
|
||||
})
|
||||
|
||||
t.Run("when FileInfo doesn't exist", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, fileInfo)
|
||||
assert.NotNil(t, seeker)
|
||||
})
|
||||
|
||||
t.Run("when FileInfo exists but FileInfo.Path is not set", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
|
||||
Id: "fileInfoID",
|
||||
Path: "",
|
||||
}, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, fileInfo)
|
||||
assert.NotNil(t, seeker)
|
||||
})
|
||||
}
|
||||
74
server/boards/app/helper_test.go
Обычный файл
74
server/boards/app/helper_test.go
Обычный файл
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/metrics"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mmpermissions"
|
||||
mmpermissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mmpermissions/mocks"
|
||||
permissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mocks"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/store/mockstore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/webhook"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/ws"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore/mocks"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type TestHelper struct {
|
||||
App *App
|
||||
Store *mockstore.MockStore
|
||||
FilesBackend *mocks.FileBackend
|
||||
logger mlog.LoggerIFace
|
||||
API *mmpermissionsMocks.MockAPI
|
||||
}
|
||||
|
||||
func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
|
||||
ctrl := gomock.NewController(t)
|
||||
cfg := config.Configuration{}
|
||||
store := mockstore.NewMockStore(ctrl)
|
||||
filesBackend := &mocks.FileBackend{}
|
||||
auth := auth.New(&cfg, store, nil)
|
||||
logger := mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)
|
||||
sessionToken := "TESTTOKEN"
|
||||
wsserver := ws.NewServer(auth, sessionToken, false, logger, store)
|
||||
webhook := webhook.NewClient(&cfg, logger)
|
||||
metricsService := metrics.NewMetrics(metrics.InstanceInfo{})
|
||||
|
||||
mockStore := permissionsMocks.NewMockStore(ctrl)
|
||||
mockAPI := mmpermissionsMocks.NewMockAPI(ctrl)
|
||||
permissions := mmpermissions.New(mockStore, mockAPI, mlog.CreateConsoleTestLogger(true, mlog.LvlError))
|
||||
|
||||
appServices := Services{
|
||||
Auth: auth,
|
||||
Store: store,
|
||||
FilesBackend: filesBackend,
|
||||
Webhook: webhook,
|
||||
Metrics: metricsService,
|
||||
Logger: logger,
|
||||
SkipTemplateInit: true,
|
||||
Permissions: permissions,
|
||||
}
|
||||
app2 := New(&cfg, wsserver, appServices)
|
||||
|
||||
tearDown := func() {
|
||||
app2.Shutdown()
|
||||
if logger != nil {
|
||||
_ = logger.Shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
return &TestHelper{
|
||||
App: app2,
|
||||
Store: store,
|
||||
FilesBackend: filesBackend,
|
||||
logger: logger,
|
||||
API: mockAPI,
|
||||
}, tearDown
|
||||
}
|
||||
449
server/boards/app/import.go
Обычный файл
449
server/boards/app/import.go
Обычный файл
@@ -0,0 +1,449 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/krolaw/zipstream"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
archiveVersion = 2
|
||||
legacyFileBegin = "{\"version\":1"
|
||||
)
|
||||
|
||||
var (
|
||||
errBlockIsNotABoard = errors.New("block is not a board")
|
||||
)
|
||||
|
||||
// ImportArchive imports an archive containing zero or more boards, plus all
|
||||
// associated content, including cards, content blocks, views, and images.
|
||||
//
|
||||
// Archives are ZIP files containing a `version.json` file and zero or more
|
||||
// directories, each containing a `board.jsonl` and zero or more image files.
|
||||
func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
|
||||
// peek at the first bytes to see if this is a legacy archive format
|
||||
br := bufio.NewReader(r)
|
||||
peek, err := br.Peek(len(legacyFileBegin))
|
||||
if err == nil && string(peek) == legacyFileBegin {
|
||||
a.logger.Debug("importing legacy archive")
|
||||
_, errImport := a.ImportBoardJSONL(br, opt)
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after importing a legacy file",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return errImport
|
||||
}
|
||||
|
||||
a.logger.Debug("importing archive")
|
||||
zr := zipstream.NewReader(br)
|
||||
|
||||
boardMap := make(map[string]string) // maps old board ids to new
|
||||
|
||||
for {
|
||||
hdr, err := zr.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
a.logger.Debug("import archive - done", mlog.Int("boards_imported", len(boardMap)))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
dir, filename := filepath.Split(hdr.Name)
|
||||
dir = path.Clean(dir)
|
||||
|
||||
switch filename {
|
||||
case "version.json":
|
||||
ver, errVer := parseVersionFile(zr)
|
||||
if errVer != nil {
|
||||
return errVer
|
||||
}
|
||||
if ver != archiveVersion {
|
||||
return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion)
|
||||
}
|
||||
case "board.jsonl":
|
||||
boardID, err := a.ImportBoardJSONL(zr, opt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot import board %s: %w", dir, err)
|
||||
}
|
||||
boardMap[dir] = boardID
|
||||
default:
|
||||
// import file/image; dir is the old board id
|
||||
boardID, ok := boardMap[dir]
|
||||
if !ok {
|
||||
a.logger.Warn("skipping orphan image in archive",
|
||||
mlog.String("dir", dir),
|
||||
mlog.String("filename", filename),
|
||||
)
|
||||
continue
|
||||
}
|
||||
// save file with original filename so it matches name in image block.
|
||||
filePath := filepath.Join(opt.TeamID, boardID, filename)
|
||||
_, err := a.filesBackend.WriteFile(zr, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Trace("import archive file",
|
||||
mlog.String("dir", dir),
|
||||
mlog.String("filename", filename),
|
||||
)
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after importing an archive",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting
|
||||
// board id is returned.
|
||||
func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (string, error) {
|
||||
// TODO: Stream this once `model.GenerateBlockIDs` can take a stream of blocks.
|
||||
// We don't want to load the whole file in memory, even though it's a single board.
|
||||
boardsAndBlocks := &model.BoardsAndBlocks{
|
||||
Blocks: make([]*model.Block, 0, 10),
|
||||
Boards: make([]*model.Board, 0, 10),
|
||||
}
|
||||
lineReader := bufio.NewReader(r)
|
||||
|
||||
userID := opt.ModifiedBy
|
||||
if userID == model.SingleUser {
|
||||
userID = ""
|
||||
}
|
||||
now := utils.GetMillis()
|
||||
var boardID string
|
||||
var boardMembers []*model.BoardMember
|
||||
|
||||
lineNum := 1
|
||||
firstLine := true
|
||||
for {
|
||||
line, errRead := readLine(lineReader)
|
||||
if len(line) != 0 {
|
||||
var skip bool
|
||||
if firstLine {
|
||||
// first line might be a header tag (old archive format)
|
||||
if strings.HasPrefix(string(line), legacyFileBegin) {
|
||||
skip = true
|
||||
}
|
||||
}
|
||||
|
||||
if !skip {
|
||||
var archiveLine model.ArchiveLine
|
||||
if err := json.Unmarshal(line, &archiveLine); err != nil {
|
||||
return "", fmt.Errorf("error parsing archive line %d: %w", lineNum, err)
|
||||
}
|
||||
|
||||
// first line must be a board
|
||||
if firstLine && archiveLine.Type == "block" {
|
||||
archiveLine.Type = "board_block"
|
||||
}
|
||||
|
||||
switch archiveLine.Type {
|
||||
case "board":
|
||||
var board model.Board
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &board); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
board.ModifiedBy = userID
|
||||
board.UpdateAt = now
|
||||
board.TeamID = opt.TeamID
|
||||
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, &board)
|
||||
boardID = board.ID
|
||||
case "board_block":
|
||||
// legacy archives encoded boards as blocks; we need to convert them to real boards.
|
||||
var block *model.Block
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
block.ModifiedBy = userID
|
||||
block.UpdateAt = now
|
||||
board, err := a.blockToBoard(block, opt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err)
|
||||
}
|
||||
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, board)
|
||||
boardID = board.ID
|
||||
case "block":
|
||||
var block *model.Block
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
|
||||
return "", fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
block.ModifiedBy = userID
|
||||
block.UpdateAt = now
|
||||
block.BoardID = boardID
|
||||
boardsAndBlocks.Blocks = append(boardsAndBlocks.Blocks, block)
|
||||
case "boardMember":
|
||||
var boardMember *model.BoardMember
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &boardMember); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
boardMembers = append(boardMembers, boardMember)
|
||||
default:
|
||||
return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
|
||||
}
|
||||
firstLine = false
|
||||
}
|
||||
}
|
||||
|
||||
if errRead != nil {
|
||||
if errors.Is(errRead, io.EOF) {
|
||||
break
|
||||
}
|
||||
return "", fmt.Errorf("error reading archive line %d: %w", lineNum, errRead)
|
||||
}
|
||||
lineNum++
|
||||
}
|
||||
|
||||
// loop to remove the people how are not part of the team and system
|
||||
for i := len(boardMembers) - 1; i >= 0; i-- {
|
||||
if _, err := a.GetUser(boardMembers[i].UserID); err != nil {
|
||||
boardMembers = append(boardMembers[:i], boardMembers[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
a.fixBoardsandBlocks(boardsAndBlocks, opt)
|
||||
|
||||
var err error
|
||||
boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating archive block IDs: %w", err)
|
||||
}
|
||||
|
||||
boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error inserting archive blocks: %w", err)
|
||||
}
|
||||
|
||||
// add users to all the new boards (if not the fake system user).
|
||||
for _, board := range boardsAndBlocks.Boards {
|
||||
// make sure an admin user gets added
|
||||
adminMember := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: opt.ModifiedBy,
|
||||
SchemeAdmin: true,
|
||||
}
|
||||
if _, err2 := a.AddMemberToBoard(adminMember); err2 != nil {
|
||||
return "", fmt.Errorf("cannot add adminMember to board: %w", err2)
|
||||
}
|
||||
for _, boardMember := range boardMembers {
|
||||
bm := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: boardMember.UserID,
|
||||
Roles: boardMember.Roles,
|
||||
MinimumRole: boardMember.MinimumRole,
|
||||
SchemeAdmin: boardMember.SchemeAdmin,
|
||||
SchemeEditor: boardMember.SchemeEditor,
|
||||
SchemeCommenter: boardMember.SchemeCommenter,
|
||||
SchemeViewer: boardMember.SchemeViewer,
|
||||
Synthetic: boardMember.Synthetic,
|
||||
}
|
||||
if _, err2 := a.AddMemberToBoard(bm); err2 != nil {
|
||||
return "", fmt.Errorf("cannot add member to board: %w", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find new board id
|
||||
for _, board := range boardsAndBlocks.Boards {
|
||||
return board.ID, nil
|
||||
}
|
||||
return "", fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock)
|
||||
}
|
||||
|
||||
// fixBoardsandBlocks allows the caller of `ImportArchive` to modify or filters boards and blocks being
|
||||
// imported via callbacks.
|
||||
func (a *App) fixBoardsandBlocks(boardsAndBlocks *model.BoardsAndBlocks, opt model.ImportArchiveOptions) {
|
||||
if opt.BlockModifier == nil && opt.BoardModifier == nil {
|
||||
return
|
||||
}
|
||||
|
||||
modInfoCache := make(map[string]interface{})
|
||||
modBoards := make([]*model.Board, 0, len(boardsAndBlocks.Boards))
|
||||
modBlocks := make([]*model.Block, 0, len(boardsAndBlocks.Blocks))
|
||||
|
||||
for _, board := range boardsAndBlocks.Boards {
|
||||
b := *board
|
||||
if opt.BoardModifier != nil && !opt.BoardModifier(&b, modInfoCache) {
|
||||
a.logger.Debug("skipping insert board per board modifier",
|
||||
mlog.String("boardID", board.ID),
|
||||
)
|
||||
continue
|
||||
}
|
||||
modBoards = append(modBoards, &b)
|
||||
}
|
||||
|
||||
for _, block := range boardsAndBlocks.Blocks {
|
||||
b := block
|
||||
if opt.BlockModifier != nil && !opt.BlockModifier(b, modInfoCache) {
|
||||
a.logger.Debug("skipping insert block per block modifier",
|
||||
mlog.String("blockID", block.ID),
|
||||
)
|
||||
continue
|
||||
}
|
||||
modBlocks = append(modBlocks, b)
|
||||
}
|
||||
|
||||
boardsAndBlocks.Boards = modBoards
|
||||
boardsAndBlocks.Blocks = modBlocks
|
||||
}
|
||||
|
||||
// blockToBoard converts a `model.Block` to `model.Board`. Legacy archive formats encode boards as blocks
|
||||
// and need conversion during import.
|
||||
func (a *App) blockToBoard(block *model.Block, opt model.ImportArchiveOptions) (*model.Board, error) {
|
||||
if block.Type != model.TypeBoard {
|
||||
return nil, errBlockIsNotABoard
|
||||
}
|
||||
|
||||
board := &model.Board{
|
||||
ID: block.ID,
|
||||
TeamID: opt.TeamID,
|
||||
CreatedBy: block.CreatedBy,
|
||||
ModifiedBy: block.ModifiedBy,
|
||||
Type: model.BoardTypePrivate,
|
||||
Title: block.Title,
|
||||
CreateAt: block.CreateAt,
|
||||
UpdateAt: block.UpdateAt,
|
||||
DeleteAt: block.DeleteAt,
|
||||
Properties: make(map[string]interface{}),
|
||||
CardProperties: make([]map[string]interface{}, 0),
|
||||
}
|
||||
|
||||
if icon, ok := stringValue(block.Fields, "icon"); ok {
|
||||
board.Icon = icon
|
||||
}
|
||||
if description, ok := stringValue(block.Fields, "description"); ok {
|
||||
board.Description = description
|
||||
}
|
||||
if showDescription, ok := boolValue(block.Fields, "showDescription"); ok {
|
||||
board.ShowDescription = showDescription
|
||||
}
|
||||
if isTemplate, ok := boolValue(block.Fields, "isTemplate"); ok {
|
||||
board.IsTemplate = isTemplate
|
||||
}
|
||||
if templateVer, ok := intValue(block.Fields, "templateVer"); ok {
|
||||
board.TemplateVersion = templateVer
|
||||
}
|
||||
if properties, ok := mapValue(block.Fields, "properties"); ok {
|
||||
board.Properties = properties
|
||||
}
|
||||
if cardProperties, ok := arrayMapsValue(block.Fields, "cardProperties"); ok {
|
||||
board.CardProperties = cardProperties
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func stringValue(m map[string]interface{}, key string) (string, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
func boolValue(m map[string]interface{}, key string) (bool, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
func intValue(m map[string]interface{}, key string) (int, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
i, ok := v.(int)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
func mapValue(m map[string]interface{}, key string) (map[string]interface{}, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
mm, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return mm, true
|
||||
}
|
||||
|
||||
func arrayMapsValue(m map[string]interface{}, key string) ([]map[string]interface{}, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ai, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
arr := make([]map[string]interface{}, 0, len(ai))
|
||||
for _, mi := range ai {
|
||||
mm, ok := mi.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
arr = append(arr, mm)
|
||||
}
|
||||
return arr, true
|
||||
}
|
||||
|
||||
func parseVersionFile(r io.Reader) (int, error) {
|
||||
file, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot read version.json: %w", err)
|
||||
}
|
||||
|
||||
var header model.ArchiveHeader
|
||||
if err := json.Unmarshal(file, &header); err != nil {
|
||||
return 0, fmt.Errorf("cannot parse version.json: %w", err)
|
||||
}
|
||||
return header.Version, nil
|
||||
}
|
||||
|
||||
func readLine(r *bufio.Reader) ([]byte, error) {
|
||||
line, err := r.ReadBytes('\n')
|
||||
line = bytes.TrimSpace(line)
|
||||
return line, err
|
||||
}
|
||||
169
server/boards/app/import_test.go
Обычный файл
169
server/boards/app/import_test.go
Обычный файл
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestApp_ImportArchive(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
board := &model.Board{
|
||||
ID: "d14b9df9-1f31-4732-8a64-92bc7162cd28",
|
||||
TeamID: "test-team",
|
||||
Title: "Cross-Functional Project Plan",
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: "2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e",
|
||||
ParentID: board.ID,
|
||||
Type: model.TypeView,
|
||||
BoardID: board.ID,
|
||||
}
|
||||
|
||||
babs := &model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{board},
|
||||
Blocks: []*model.Block{block},
|
||||
}
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "user",
|
||||
}
|
||||
|
||||
t.Run("import asana archive", func(t *testing.T) {
|
||||
r := bytes.NewReader([]byte(asana))
|
||||
opts := model.ImportArchiveOptions{
|
||||
TeamID: "test-team",
|
||||
ModifiedBy: "user",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "user").Return(babs, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{boardMember}, nil)
|
||||
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(board.ID, "user").Return(boardMember, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
Type: "default",
|
||||
Name: "Boards",
|
||||
ID: "boards_category_id",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team")
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything, utils.Anything).Return(nil)
|
||||
|
||||
err := th.App.ImportArchive(r, opts)
|
||||
require.NoError(t, err, "import archive should not fail")
|
||||
})
|
||||
|
||||
t.Run("import board archive", func(t *testing.T) {
|
||||
r := bytes.NewReader([]byte(boardArchive))
|
||||
opts := model.ImportArchiveOptions{
|
||||
TeamID: "test-team",
|
||||
ModifiedBy: "f1tydgc697fcbp8ampr6881jea",
|
||||
}
|
||||
|
||||
bm1 := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "f1tydgc697fcbp8ampr6881jea",
|
||||
}
|
||||
|
||||
bm2 := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "hxxzooc3ff8cubsgtcmpn8733e",
|
||||
}
|
||||
|
||||
bm3 := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "nto73edn5ir6ifimo5a53y1dwa",
|
||||
}
|
||||
|
||||
user1 := &model.User{
|
||||
ID: "f1tydgc697fcbp8ampr6881jea",
|
||||
}
|
||||
|
||||
user2 := &model.User{
|
||||
ID: "hxxzooc3ff8cubsgtcmpn8733e",
|
||||
}
|
||||
|
||||
user3 := &model.User{
|
||||
ID: "nto73edn5ir6ifimo5a53y1dwa",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "f1tydgc697fcbp8ampr6881jea").Return(babs, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{bm1, bm2, bm3}, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{}, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
Type: model.CategoryTypeSystem,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("f1tydgc697fcbp8ampr6881jea").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("f1tydgc697fcbp8ampr6881jea", "test-team", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("f1tydgc697fcbp8ampr6881jea", utils.Anything, utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(board.ID, "f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(bm1, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(board.ID, "hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(bm2, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(board.ID, "nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(bm3, nil)
|
||||
th.Store.EXPECT().GetUserByID("f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(user1, nil)
|
||||
th.Store.EXPECT().GetUserByID("hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(user2, nil)
|
||||
th.Store.EXPECT().GetUserByID("nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(user3, nil)
|
||||
|
||||
boardID, err := th.App.ImportBoardJSONL(r, opts)
|
||||
require.Equal(t, board.ID, boardID, "Board ID should be same")
|
||||
require.NoError(t, err, "import archive should not fail")
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:lll
|
||||
const asana = `{"version":1,"date":1614714686842}
|
||||
{"type":"block","data":{"id":"d14b9df9-1f31-4732-8a64-92bc7162cd28","fields":{"icon":"","description":"","cardProperties":[{"id":"3bdcbaeb-bc78-4884-8531-a0323b74676a","name":"Section","type":"select","options":[{"id":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732","value":"Planning","color":"propColorGray"},{"id":"454559bb-b788-4ff6-873e-04def8491d2c","value":"Milestones","color":"propColorBrown"},{"id":"deaab476-c690-48df-828f-725b064dc476","value":"Next steps","color":"propColorOrange"},{"id":"2138305a-3157-461c-8bbe-f19ebb55846d","value":"Comms Plan","color":"propColorYellow"}]}]},"createAt":1614714686836,"updateAt":1614714686836,"deleteAt":0,"schema":1,"parentId":"","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"board","title":"Cross-Functional Project Plan"}}
|
||||
{"type":"block","data":{"id":"2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e","fields":{"sortOptions":[],"visiblePropertyIds":[],"visibleOptionIds":[],"hiddenOptionIds":[],"filter":{"operation":"and","filters":[]},"cardOrder":[],"columnWidths":{},"viewType":"board"},"createAt":1614714686840,"updateAt":1614714686840,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"view","title":"Board View"}}
|
||||
{"type":"block","data":{"id":"520c332b-adf5-4a32-88ab-43655c8b6aa2","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["deb3966c-6d56-43b1-8e95-36806877ce81"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[READ ME] - Instructions for using this template"}}
|
||||
{"type":"block","data":{"id":"deb3966c-6d56-43b1-8e95-36806877ce81","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"520c332b-adf5-4a32-88ab-43655c8b6aa2","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"This project template is set up in List View with sections and Asana-created Custom Fields to help you track your team's work. We've provided some example content in this template to get you started, but you should add tasks, change task names, add more Custom Fields, and change any other info to make this project your own.\n\nSend feedback about this template: https://asa.na/templatesfeedback"}}
|
||||
{"type":"block","data":{"id":"be791f66-a5e5-4408-82f6-cb1280f5bc45","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["2688b31f-e7ff-4de1-87ae-d4b5570f8712"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"Redesign the landing page of our website"}}
|
||||
{"type":"block","data":{"id":"2688b31f-e7ff-4de1-87ae-d4b5570f8712","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"be791f66-a5e5-4408-82f6-cb1280f5bc45","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"Redesign the landing page to focus on the main persona."}}
|
||||
{"type":"block","data":{"id":"98f74948-1700-4a3c-8cc2-8bb632499def","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Consider trying a new email marketing service"}}
|
||||
{"type":"block","data":{"id":"142fba5d-05e6-4865-83d9-b3f54d9de96e","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Budget finalization"}}
|
||||
{"type":"block","data":{"id":"ca6670b1-b034-4e42-8971-c659b478b9e0","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Find a venue for the holiday party"}}
|
||||
{"type":"block","data":{"id":"db1dd596-0999-4741-8b05-72ca8e438e31","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Approve campaign copy"}}
|
||||
{"type":"block","data":{"id":"16861c05-f31f-46af-8429-80a87b5aa93a","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"2138305a-3157-461c-8bbe-f19ebb55846d"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Send out updated attendee list"}}
|
||||
`
|
||||
|
||||
//nolint:lll
|
||||
const boardArchive = `{"type":"board","data":{"id":"bfoi6yy6pa3yzika53spj7pq9ee","teamId":"wsmqbtwb5jb35jb3mtp85c8a9h","channelId":"","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","type":"P","minimumRole":"","title":"Custom","description":"","icon":"","showDescription":false,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"aonihehbifijmx56aqzu3cc7w1r","name":"Status","options":[],"type":"select"},{"id":"aohjkzt769rxhtcz1o9xcoce5to","name":"Person","options":[],"type":"person"}],"createAt":1672750481591,"updateAt":1672750481591,"deleteAt":0}}
|
||||
{"type":"block","data":{"id":"ckpc3b1dp3pbw7bqntfryy9jbzo","parentId":"bjaqxtbyqz3bu7pgyddpgpms74a","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","schema":1,"type":"card","title":"Test","fields":{"contentOrder":[],"icon":"","isTemplate":false,"properties":{"aohjkzt769rxhtcz1o9xcoce5to":"hxxzooc3ff8cubsgtcmpn8733e"}},"createAt":1672750481612,"updateAt":1672845003530,"deleteAt":0,"boardId":"bfoi6yy6pa3yzika53spj7pq9ee"}}
|
||||
{"type":"block","data":{"id":"v7tdajwpm47r3u8duedk89bhxar","parentId":"bpypang3a3errqstj1agx9kuqay","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","schema":1,"type":"view","title":"Board view","fields":{"cardOrder":["crsyw7tbr3pnjznok6ppngmmyya","c5titiemp4pgaxbs4jksgybbj4y"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":[],"visiblePropertyIds":["aohjkzt769rxhtcz1o9xcoce5to"]},"createAt":1672750481626,"updateAt":1672750481626,"deleteAt":0,"boardId":"bfoi6yy6pa3yzika53spj7pq9ee"}}
|
||||
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"f1tydgc697fcbp8ampr6881jea","roles":"","minimumRole":"","schemeAdmin":false,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":true,"synthetic":false}}
|
||||
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"hxxzooc3ff8cubsgtcmpn8733e","roles":"","minimumRole":"","schemeAdmin":false,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":true,"synthetic":false}}
|
||||
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"nto73edn5ir6ifimo5a53y1dwa","roles":"","minimumRole":"","schemeAdmin":true,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":false,"synthetic":false}}
|
||||
`
|
||||
29
server/boards/app/initialize.go
Обычный файл
29
server/boards/app/initialize.go
Обычный файл
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// initialize is called when the App is first created.
|
||||
func (a *App) initialize(skipTemplateInit bool) {
|
||||
if !skipTemplateInit {
|
||||
if err := a.InitTemplates(); err != nil {
|
||||
a.logger.Error(`InitializeTemplates failed`, mlog.Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Shutdown() {
|
||||
if a.blockChangeNotifier != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), blockChangeNotifierShutdownTimeout)
|
||||
defer cancel()
|
||||
if !a.blockChangeNotifier.Shutdown(ctx) {
|
||||
a.logger.Warn("blockChangeNotifier shutdown timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
87
server/boards/app/insights.go
Обычный файл
87
server/boards/app/insights.go
Обычный файл
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) GetTeamBoardsInsights(userID string, teamID string, opts *mm_model.InsightsOpts) (*model.BoardInsightsList, error) {
|
||||
// check if server is properly licensed, and user is not a guest
|
||||
userPermitted, err := insightPermissionGate(a, userID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !userPermitted {
|
||||
return nil, errors.New("User isn't authorized to access insights.")
|
||||
}
|
||||
boardIDs, err := getUserBoards(userID, teamID, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.store.GetTeamBoardsInsights(teamID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage, boardIDs)
|
||||
}
|
||||
|
||||
func (a *App) GetUserBoardsInsights(userID string, teamID string, opts *mm_model.InsightsOpts) (*model.BoardInsightsList, error) {
|
||||
// check if server is properly licensed, and user is not a guest
|
||||
userPermitted, err := insightPermissionGate(a, userID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !userPermitted {
|
||||
return nil, errors.New("User isn't authorized to access insights.")
|
||||
}
|
||||
boardIDs, err := getUserBoards(userID, teamID, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.store.GetUserBoardsInsights(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage, boardIDs)
|
||||
}
|
||||
|
||||
func insightPermissionGate(a *App, userID string, isMyInsights bool) (bool, error) {
|
||||
licenseError := errors.New("invalid license/authorization to use insights API")
|
||||
guestError := errors.New("guests aren't authorized to use insights API")
|
||||
lic := a.store.GetLicense()
|
||||
|
||||
user, err := a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if user.IsGuest {
|
||||
return false, guestError
|
||||
}
|
||||
|
||||
if lic == nil && !isMyInsights {
|
||||
a.logger.Debug("Deployment doesn't have a license")
|
||||
return false, licenseError
|
||||
}
|
||||
|
||||
if !isMyInsights && (lic.SkuShortName != mm_model.LicenseShortSkuProfessional && lic.SkuShortName != mm_model.LicenseShortSkuEnterprise) {
|
||||
return false, licenseError
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUserTimezone(userID string) (string, error) {
|
||||
return a.store.GetUserTimezone(userID)
|
||||
}
|
||||
|
||||
func getUserBoards(userID string, teamID string, a *App) ([]string, error) {
|
||||
// get boards accessible by user and filter boardIDs
|
||||
boards, err := a.store.GetBoardsForUserAndTeam(userID, teamID, true)
|
||||
if err != nil {
|
||||
return nil, errors.New("error getting boards for user")
|
||||
}
|
||||
boardIDs := make([]string, 0, len(boards))
|
||||
|
||||
for _, board := range boards {
|
||||
boardIDs = append(boardIDs, board.ID)
|
||||
}
|
||||
return boardIDs, nil
|
||||
}
|
||||
93
server/boards/app/insights_test.go
Обычный файл
93
server/boards/app/insights_test.go
Обычный файл
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
var mockInsightsBoards = []*model.Board{
|
||||
{
|
||||
ID: "mock-user-workspace-id",
|
||||
Title: "MockUserWorkspace",
|
||||
},
|
||||
}
|
||||
|
||||
var mockTeamInsights = []*model.BoardInsight{
|
||||
{
|
||||
BoardID: "board-id-1",
|
||||
},
|
||||
{
|
||||
BoardID: "board-id-2",
|
||||
},
|
||||
}
|
||||
|
||||
var mockTeamInsightsList = &model.BoardInsightsList{
|
||||
InsightsListData: mm_model.InsightsListData{HasNext: false},
|
||||
Items: mockTeamInsights,
|
||||
}
|
||||
|
||||
type insightError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (ie insightError) Error() string {
|
||||
return ie.msg
|
||||
}
|
||||
|
||||
func TestGetTeamAndUserBoardsInsights(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success query", func(t *testing.T) {
|
||||
fakeLicense := &mm_model.License{Features: &mm_model.Features{}, SkuShortName: mm_model.LicenseShortSkuEnterprise}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense).AnyTimes()
|
||||
fakeUser := &model.User{
|
||||
ID: "user-id",
|
||||
IsGuest: false,
|
||||
}
|
||||
th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id", true).Return(mockInsightsBoards, nil).AnyTimes()
|
||||
th.Store.EXPECT().
|
||||
GetTeamBoardsInsights("team-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(mockTeamInsightsList, nil)
|
||||
results, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Items, 2)
|
||||
th.Store.EXPECT().
|
||||
GetUserBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(mockTeamInsightsList, nil)
|
||||
results, err = th.App.GetUserBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Items, 2)
|
||||
})
|
||||
|
||||
t.Run("fail query", func(t *testing.T) {
|
||||
fakeLicense := &mm_model.License{Features: &mm_model.Features{}, SkuShortName: mm_model.LicenseShortSkuEnterprise}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense).AnyTimes()
|
||||
fakeUser := &model.User{
|
||||
ID: "user-id",
|
||||
IsGuest: false,
|
||||
}
|
||||
th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id", true).Return(mockInsightsBoards, nil).AnyTimes()
|
||||
th.Store.EXPECT().
|
||||
GetTeamBoardsInsights("team-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(nil, insightError{"board-insight-error"})
|
||||
_, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, insightError{"board-insight-error"})
|
||||
th.Store.EXPECT().
|
||||
GetUserBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
|
||||
Return(nil, insightError{"board-insight-error"})
|
||||
_, err = th.App.GetUserBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, insightError{"board-insight-error"})
|
||||
})
|
||||
}
|
||||
99
server/boards/app/onboarding.go
Обычный файл
99
server/boards/app/onboarding.go
Обычный файл
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
const (
|
||||
KeyOnboardingTourStarted = "onboardingTourStarted"
|
||||
KeyOnboardingTourCategory = "tourCategory"
|
||||
KeyOnboardingTourStep = "onboardingTourStep"
|
||||
|
||||
ValueOnboardingFirstStep = "0"
|
||||
ValueTourCategoryOnboarding = "onboarding"
|
||||
|
||||
WelcomeBoardTitle = "Welcome to Boards!"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnableToFindWelcomeBoard = errors.New("unable to find welcome board in newly created blocks")
|
||||
errCannotCreateBoard = errors.New("new board wasn't created")
|
||||
)
|
||||
|
||||
func (a *App) PrepareOnboardingTour(userID string, teamID string) (string, string, error) {
|
||||
// copy the welcome board into this workspace
|
||||
boardID, err := a.createWelcomeBoard(userID, teamID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// set user's tour state to initial state
|
||||
userPreferencesPatch := model.UserPreferencesPatch{
|
||||
UpdatedFields: map[string]string{
|
||||
KeyOnboardingTourStarted: "1",
|
||||
KeyOnboardingTourStep: ValueOnboardingFirstStep,
|
||||
KeyOnboardingTourCategory: ValueTourCategoryOnboarding,
|
||||
},
|
||||
}
|
||||
if _, err := a.store.PatchUserPreferences(userID, userPreferencesPatch); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return teamID, boardID, nil
|
||||
}
|
||||
|
||||
func (a *App) getOnboardingBoardID() (string, error) {
|
||||
boards, err := a.store.GetTemplateBoards(model.GlobalTeamID, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var onboardingBoardID string
|
||||
for _, block := range boards {
|
||||
if block.Title == WelcomeBoardTitle && block.TeamID == model.GlobalTeamID {
|
||||
onboardingBoardID = block.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if onboardingBoardID == "" {
|
||||
return "", errUnableToFindWelcomeBoard
|
||||
}
|
||||
|
||||
return onboardingBoardID, nil
|
||||
}
|
||||
|
||||
func (a *App) createWelcomeBoard(userID, teamID string) (string, error) {
|
||||
onboardingBoardID, err := a.getOnboardingBoardID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
bab, _, err := a.DuplicateBoard(onboardingBoardID, userID, teamID, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(bab.Boards) != 1 {
|
||||
return "", errCannotCreateBoard
|
||||
}
|
||||
|
||||
// need variable for this to
|
||||
// get reference for board patch
|
||||
newType := model.BoardTypePrivate
|
||||
|
||||
patch := &model.BoardPatch{
|
||||
Type: &newType,
|
||||
}
|
||||
|
||||
if _, err := a.PatchBoard(patch, bab.Boards[0].ID, userID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return bab.Boards[0].ID, nil
|
||||
}
|
||||
197
server/boards/app/onboarding_test.go
Обычный файл
197
server/boards/app/onboarding_test.go
Обычный файл
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
const (
|
||||
testTeamID = "team_id"
|
||||
)
|
||||
|
||||
func TestPrepareOnboardingTour(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
userID := "user_id_1"
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{
|
||||
{
|
||||
ID: "board_id_2",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
},
|
||||
}},
|
||||
nil, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
th.Store.EXPECT().GetMembersForBoard("board_id_2").Return([]*model.BoardMember{}, nil).Times(1)
|
||||
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).Times(2)
|
||||
th.Store.EXPECT().GetBoard("board_id_2").Return(&welcomeBoard, nil).Times(1)
|
||||
th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil)
|
||||
|
||||
privateWelcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
Type: model.BoardTypePrivate,
|
||||
}
|
||||
newType := model.BoardTypePrivate
|
||||
th.Store.EXPECT().PatchBoard("board_id_2", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id_1").Return([]*model.BoardMember{}, nil)
|
||||
|
||||
userPreferencesPatch := model.UserPreferencesPatch{
|
||||
UpdatedFields: map[string]string{
|
||||
KeyOnboardingTourStarted: "1",
|
||||
KeyOnboardingTourStep: ValueOnboardingFirstStep,
|
||||
KeyOnboardingTourCategory: ValueTourCategoryOnboarding,
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().PatchUserPreferences(userID, userPreferencesPatch).Return(nil, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{}, nil).Times(1)
|
||||
|
||||
// when this is called the second time, the default category is created so we need to include that in the response list
|
||||
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil).Times(1)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "boards_category",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id_1", teamID, false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_2"}).Return(nil)
|
||||
|
||||
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testTeamID, teamID)
|
||||
assert.NotEmpty(t, boardID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateWelcomeBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
userID := "user_id_1"
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
}
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).
|
||||
Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(3)
|
||||
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).AnyTimes()
|
||||
th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil)
|
||||
|
||||
privateWelcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
Type: model.BoardTypePrivate,
|
||||
}
|
||||
newType := model.BoardTypePrivate
|
||||
th.Store.EXPECT().PatchBoard("board_id_1", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
|
||||
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
|
||||
},
|
||||
}, nil).Times(3)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_1"}).Return(nil)
|
||||
|
||||
boardID, err := th.App.createWelcomeBoard(userID, teamID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, boardID)
|
||||
})
|
||||
|
||||
t.Run("template doesn't contain a board", func(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil)
|
||||
boardID, err := th.App.createWelcomeBoard("user_id_1", teamID)
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, boardID)
|
||||
})
|
||||
|
||||
t.Run("template doesn't contain the welcome board", func(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Other template",
|
||||
TeamID: teamID,
|
||||
IsTemplate: true,
|
||||
}
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1")
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, boardID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetOnboardingBoardID(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Welcome to Boards!",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
}
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
|
||||
onboardingBoardID, err := th.App.getOnboardingBoardID()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "board_id_1", onboardingBoardID)
|
||||
})
|
||||
|
||||
t.Run("no blocks found", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil)
|
||||
|
||||
onboardingBoardID, err := th.App.getOnboardingBoardID()
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, onboardingBoardID)
|
||||
})
|
||||
|
||||
t.Run("onboarding board doesn't exists", func(t *testing.T) {
|
||||
welcomeBoard := model.Board{
|
||||
ID: "board_id_1",
|
||||
Title: "Other template",
|
||||
TeamID: "0",
|
||||
IsTemplate: true,
|
||||
}
|
||||
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
|
||||
|
||||
onboardingBoardID, err := th.App.getOnboardingBoardID()
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, onboardingBoardID)
|
||||
})
|
||||
}
|
||||
12
server/boards/app/permissions.go
Обычный файл
12
server/boards/app/permissions.go
Обычный файл
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func (a *App) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool {
|
||||
return a.permissions.HasPermissionToBoard(userID, boardID, permission)
|
||||
}
|
||||
45
server/boards/app/server_metadata.go
Обычный файл
45
server/boards/app/server_metadata.go
Обычный файл
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type ServerMetadata struct {
|
||||
Version string `json:"version"`
|
||||
BuildNumber string `json:"build_number"`
|
||||
BuildDate string `json:"build_date"`
|
||||
Commit string `json:"commit"`
|
||||
Edition string `json:"edition"`
|
||||
DBType string `json:"db_type"`
|
||||
DBVersion string `json:"db_version"`
|
||||
OSType string `json:"os_type"`
|
||||
OSArch string `json:"os_arch"`
|
||||
SKU string `json:"sku"`
|
||||
}
|
||||
|
||||
func (a *App) GetServerMetadata() *ServerMetadata {
|
||||
var dbType string
|
||||
var dbVersion string
|
||||
if a != nil && a.store != nil {
|
||||
dbType = a.store.DBType()
|
||||
dbVersion = a.store.DBVersion()
|
||||
}
|
||||
|
||||
return &ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: dbType,
|
||||
DBVersion: dbVersion,
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_server",
|
||||
}
|
||||
}
|
||||
40
server/boards/app/server_metadata_test.go
Обычный файл
40
server/boards/app/server_metadata_test.go
Обычный файл
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestGetServerMetadata(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().DBType().Return("TEST_DB_TYPE")
|
||||
th.Store.EXPECT().DBVersion().Return("TEST_DB_VERSION")
|
||||
|
||||
t.Run("Get Server Metadata", func(t *testing.T) {
|
||||
got := th.App.GetServerMetadata()
|
||||
want := &ServerMetadata{
|
||||
Version: model.CurrentVersion,
|
||||
BuildNumber: model.BuildNumber,
|
||||
BuildDate: model.BuildDate,
|
||||
Commit: model.BuildHash,
|
||||
Edition: model.Edition,
|
||||
DBType: "TEST_DB_TYPE",
|
||||
DBVersion: "TEST_DB_VERSION",
|
||||
OSType: runtime.GOOS,
|
||||
OSArch: runtime.GOARCH,
|
||||
SKU: "personal_server",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got: %q, want: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
20
server/boards/app/sharing.go
Обычный файл
20
server/boards/app/sharing.go
Обычный файл
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) GetSharing(boardID string) (*model.Sharing, error) {
|
||||
sharing, err := a.store.GetSharing(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sharing, nil
|
||||
}
|
||||
|
||||
func (a *App) UpsertSharing(sharing model.Sharing) error {
|
||||
return a.store.UpsertSharing(sharing)
|
||||
}
|
||||
88
server/boards/app/sharing_test.go
Обычный файл
88
server/boards/app/sharing_test.go
Обычный файл
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
)
|
||||
|
||||
func TestGetSharing(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("should get a sharing successfully", func(t *testing.T) {
|
||||
want := &model.Sharing{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
Enabled: true,
|
||||
Token: "token",
|
||||
ModifiedBy: "otherid",
|
||||
UpdateAt: utils.GetMillis(),
|
||||
}
|
||||
th.Store.EXPECT().GetSharing("test-id").Return(want, nil)
|
||||
|
||||
result, err := th.App.GetSharing("test-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, result, want)
|
||||
require.NotNil(t, th.App)
|
||||
})
|
||||
|
||||
t.Run("should fail to get a sharing", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetSharing("test-id").Return(
|
||||
nil,
|
||||
errors.New("sharing not found"),
|
||||
)
|
||||
result, err := th.App.GetSharing("test-id")
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "sharing not found", err.Error())
|
||||
})
|
||||
|
||||
t.Run("should return a not found error", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetSharing("test-id").Return(
|
||||
nil,
|
||||
sql.ErrNoRows,
|
||||
)
|
||||
result, err := th.App.GetSharing("test-id")
|
||||
require.Error(t, err)
|
||||
require.True(t, model.IsErrNotFound(err))
|
||||
require.Nil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpsertSharing(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
sharing := model.Sharing{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
Enabled: true,
|
||||
Token: "token",
|
||||
ModifiedBy: "otherid",
|
||||
UpdateAt: utils.GetMillis(),
|
||||
}
|
||||
|
||||
t.Run("should success to upsert sharing", func(t *testing.T) {
|
||||
th.Store.EXPECT().UpsertSharing(sharing).Return(nil)
|
||||
err := th.App.UpsertSharing(sharing)
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should fail to upsert a sharing", func(t *testing.T) {
|
||||
th.Store.EXPECT().UpsertSharing(sharing).Return(errors.New("sharing not found"))
|
||||
err := th.App.UpsertSharing(sharing)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "sharing not found", err.Error())
|
||||
})
|
||||
}
|
||||
55
server/boards/app/subscriptions.go
Обычный файл
55
server/boards/app/subscriptions.go
Обычный файл
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *App) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) {
|
||||
sub, err := a.store.CreateSubscription(sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.notifySubscriptionChanged(sub)
|
||||
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteSubscription(blockID string, subscriberID string) (*model.Subscription, error) {
|
||||
sub, err := a.store.GetSubscription(blockID, subscriberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.store.DeleteSubscription(blockID, subscriberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.DeleteAt = utils.GetMillis()
|
||||
a.notifySubscriptionChanged(sub)
|
||||
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (a *App) GetSubscriptions(subscriberID string) ([]*model.Subscription, error) {
|
||||
return a.store.GetSubscriptions(subscriberID)
|
||||
}
|
||||
|
||||
func (a *App) notifySubscriptionChanged(subscription *model.Subscription) {
|
||||
if a.notifications == nil {
|
||||
return
|
||||
}
|
||||
|
||||
board, err := a.getBoardForBlock(subscription.BlockID)
|
||||
if err != nil {
|
||||
a.logger.Error("Error notifying subscription change",
|
||||
mlog.String("subscriber_id", subscription.SubscriberID),
|
||||
mlog.String("block_id", subscription.BlockID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
a.wsAdapter.BroadcastSubscriptionChange(board.TeamID, subscription)
|
||||
}
|
||||
68
server/boards/app/teams.go
Обычный файл
68
server/boards/app/teams.go
Обычный файл
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *App) GetRootTeam() (*model.Team, error) {
|
||||
teamID := "0"
|
||||
team, _ := a.store.GetTeam(teamID)
|
||||
if team == nil {
|
||||
team = &model.Team{
|
||||
ID: teamID,
|
||||
SignupToken: utils.NewID(utils.IDTypeToken),
|
||||
}
|
||||
err := a.store.UpsertTeamSignupToken(*team)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to initialize team", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
team, err = a.store.GetTeam(teamID)
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to get initialized team", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.logger.Info("initialized team")
|
||||
}
|
||||
|
||||
return team, nil
|
||||
}
|
||||
|
||||
func (a *App) GetTeam(id string) (*model.Team, error) {
|
||||
team, err := a.store.GetTeam(id)
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return team, nil
|
||||
}
|
||||
|
||||
func (a *App) GetTeamsForUser(userID string) ([]*model.Team, error) {
|
||||
return a.store.GetTeamsForUser(userID)
|
||||
}
|
||||
|
||||
func (a *App) DoesUserHaveTeamAccess(userID string, teamID string) bool {
|
||||
return a.auth.DoesUserHaveTeamAccess(userID, teamID)
|
||||
}
|
||||
|
||||
func (a *App) UpsertTeamSettings(team model.Team) error {
|
||||
return a.store.UpsertTeamSettings(team)
|
||||
}
|
||||
|
||||
func (a *App) UpsertTeamSignupToken(team model.Team) error {
|
||||
return a.store.UpsertTeamSignupToken(team)
|
||||
}
|
||||
|
||||
func (a *App) GetTeamCount() (int64, error) {
|
||||
return a.store.GetTeamCount()
|
||||
}
|
||||
158
server/boards/app/teams_test.go
Обычный файл
158
server/boards/app/teams_test.go
Обычный файл
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
var errInvalidTeam = errors.New("invalid team id")
|
||||
|
||||
var mockTeam = &model.Team{
|
||||
ID: "mock-team-id",
|
||||
Title: "MockTeam",
|
||||
}
|
||||
|
||||
var errUpsertSignupToken = errors.New("upsert error")
|
||||
|
||||
func TestGetRootTeam(t *testing.T) {
|
||||
var newRootTeam = &model.Team{
|
||||
ID: "0",
|
||||
Title: "NewRootTeam",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
title string
|
||||
teamToReturnBeforeUpsert *model.Team
|
||||
teamToReturnAfterUpsert *model.Team
|
||||
isError bool
|
||||
}{
|
||||
{
|
||||
"Success, Return new root team, when root team returned by mockstore is nil",
|
||||
nil,
|
||||
newRootTeam,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Success, Return existing root team, when root team returned by mockstore is notnil",
|
||||
newRootTeam,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Fail, Return nil, when root team returned by mockstore is nil, and upsert new root team fails",
|
||||
nil,
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
th.Store.EXPECT().GetTeam("0").Return(tc.teamToReturnBeforeUpsert, nil)
|
||||
if tc.teamToReturnBeforeUpsert == nil {
|
||||
th.Store.EXPECT().UpsertTeamSignupToken(gomock.Any()).DoAndReturn(
|
||||
func(arg0 model.Team) error {
|
||||
if tc.isError {
|
||||
return errUpsertSignupToken
|
||||
}
|
||||
th.Store.EXPECT().GetTeam("0").Return(tc.teamToReturnAfterUpsert, nil)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
rootTeam, err := th.App.GetRootTeam()
|
||||
|
||||
if tc.isError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
assert.NotNil(t, rootTeam.ID)
|
||||
assert.NotNil(t, rootTeam.SignupToken)
|
||||
assert.Equal(t, "", rootTeam.ModifiedBy)
|
||||
assert.Equal(t, int64(0), rootTeam.UpdateAt)
|
||||
assert.Equal(t, "NewRootTeam", rootTeam.Title)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rootTeam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTeam(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
testCases := []struct {
|
||||
title string
|
||||
teamID string
|
||||
isError bool
|
||||
}{
|
||||
{
|
||||
"Success, Return new root team, when team returned by mockstore is not nil",
|
||||
"mock-team-id",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Success, Return nil, when get team returns an sql error",
|
||||
"team-not-available-id",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Fail, Return nil, when get team by mockstore returns an error",
|
||||
"invalid-team-id",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("mock-team-id").Return(mockTeam, nil)
|
||||
th.Store.EXPECT().GetTeam("invalid-team-id").Return(nil, errInvalidTeam)
|
||||
th.Store.EXPECT().GetTeam("team-not-available-id").Return(nil, sql.ErrNoRows)
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
t.Log(tc.title)
|
||||
team, err := th.App.GetTeam(tc.teamID)
|
||||
|
||||
if tc.isError {
|
||||
require.Error(t, err)
|
||||
} else if tc.teamID != "team-not-available-id" {
|
||||
assert.NotNil(t, team.ID)
|
||||
assert.NotNil(t, team.SignupToken)
|
||||
assert.Equal(t, "mock-team-id", team.ID)
|
||||
assert.Equal(t, "", team.ModifiedBy)
|
||||
assert.Equal(t, int64(0), team.UpdateAt)
|
||||
assert.Equal(t, "MockTeam", team.Title)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, team)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamOperations(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().UpsertTeamSettings(*mockTeam).Return(nil)
|
||||
th.Store.EXPECT().UpsertTeamSignupToken(*mockTeam).Return(nil)
|
||||
th.Store.EXPECT().GetTeamCount().Return(int64(10), nil)
|
||||
|
||||
errUpsertTeamSettings := th.App.UpsertTeamSettings(*mockTeam)
|
||||
assert.NoError(t, errUpsertTeamSettings)
|
||||
|
||||
errUpsertTeamSignupToken := th.App.UpsertTeamSignupToken(*mockTeam)
|
||||
assert.NoError(t, errUpsertTeamSignupToken)
|
||||
|
||||
count, errGetTeamCount := th.App.GetTeamCount()
|
||||
assert.NoError(t, errGetTeamCount)
|
||||
assert.Equal(t, int64(10), count)
|
||||
}
|
||||
Двоичные данные
server/boards/app/templates.boardarchive
Обычный файл
Двоичные данные
server/boards/app/templates.boardarchive
Обычный файл
Двоичный файл не отображается.
115
server/boards/app/templates.go
Обычный файл
115
server/boards/app/templates.go
Обычный файл
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/assets"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTemplateVersion = 6 // bump this number to force default templates to be re-imported
|
||||
)
|
||||
|
||||
func (a *App) InitTemplates() error {
|
||||
_, err := a.initializeTemplates()
|
||||
return err
|
||||
}
|
||||
|
||||
// initializeTemplates imports default templates if the boards table is empty.
|
||||
func (a *App) initializeTemplates() (bool, error) {
|
||||
boards, err := a.store.GetTemplateBoards(model.GlobalTeamID, "")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot initialize templates: %w", err)
|
||||
}
|
||||
|
||||
a.logger.Debug("Fetched template boards", mlog.Int("count", len(boards)))
|
||||
|
||||
isNeeded, reason := a.isInitializationNeeded(boards)
|
||||
if !isNeeded {
|
||||
a.logger.Debug("Template import not needed, skipping")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
a.logger.Debug("Importing new default templates",
|
||||
mlog.String("reason", reason),
|
||||
mlog.Int("size", len(assets.DefaultTemplatesArchive)),
|
||||
)
|
||||
|
||||
// Remove in case of newer Templates
|
||||
if err = a.store.RemoveDefaultTemplates(boards); err != nil {
|
||||
return false, fmt.Errorf("cannot remove old template boards: %w", err)
|
||||
}
|
||||
|
||||
r := bytes.NewReader(assets.DefaultTemplatesArchive)
|
||||
|
||||
opt := model.ImportArchiveOptions{
|
||||
TeamID: model.GlobalTeamID,
|
||||
ModifiedBy: model.SystemUserID,
|
||||
BlockModifier: fixTemplateBlock,
|
||||
BoardModifier: fixTemplateBoard,
|
||||
}
|
||||
if err = a.ImportArchive(r, opt); err != nil {
|
||||
return false, fmt.Errorf("cannot initialize global templates for team %s: %w", model.GlobalTeamID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isInitializationNeeded returns true if the blocks table contains no default templates,
|
||||
// or contains at least one default template with an old version number.
|
||||
func (a *App) isInitializationNeeded(boards []*model.Board) (bool, string) {
|
||||
if len(boards) == 0 {
|
||||
return true, "no default templates found"
|
||||
}
|
||||
|
||||
// look for any built-in template boards with the wrong version number (or no version #).
|
||||
for _, board := range boards {
|
||||
// if not built-in board...skip
|
||||
if board.CreatedBy != model.SystemUserID {
|
||||
continue
|
||||
}
|
||||
if board.TemplateVersion < defaultTemplateVersion {
|
||||
return true, "template_version too old"
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// fixTemplateBlock fixes a block to be inserted as part of a template.
|
||||
func fixTemplateBlock(block *model.Block, cache map[string]interface{}) bool {
|
||||
// cache contains ids of skipped boards. Ensure their children are skipped as well.
|
||||
if _, ok := cache[block.BoardID]; ok {
|
||||
cache[block.ID] = struct{}{}
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := cache[block.ParentID]; ok {
|
||||
cache[block.ID] = struct{}{}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// fixTemplateBoard fixes a board to be inserted as part of a template.
|
||||
func fixTemplateBoard(board *model.Board, cache map[string]interface{}) bool {
|
||||
// filter out template blocks; we only want the non-template
|
||||
// blocks which we will turn into default template blocks.
|
||||
if board.IsTemplate {
|
||||
cache[board.ID] = struct{}{}
|
||||
return false
|
||||
}
|
||||
|
||||
// remove '(NEW)' from title & force template flag
|
||||
board.Title = strings.ReplaceAll(board.Title, "(NEW)", "")
|
||||
board.IsTemplate = true
|
||||
board.TemplateVersion = defaultTemplateVersion
|
||||
board.Type = model.BoardTypeOpen
|
||||
return true
|
||||
}
|
||||
74
server/boards/app/templates_test.go
Обычный файл
74
server/boards/app/templates_test.go
Обычный файл
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
|
||||
)
|
||||
|
||||
func TestApp_initializeTemplates(t *testing.T) {
|
||||
board := &model.Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
TeamID: model.GlobalTeamID,
|
||||
Type: model.BoardTypeOpen,
|
||||
Title: "test board",
|
||||
IsTemplate: true,
|
||||
TemplateVersion: defaultTemplateVersion,
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: utils.NewID(utils.IDTypeBlock),
|
||||
ParentID: board.ID,
|
||||
BoardID: board.ID,
|
||||
Type: model.TypeText,
|
||||
Title: "test text",
|
||||
}
|
||||
|
||||
boardsAndBlocks := &model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{board},
|
||||
Blocks: []*model.Block{block},
|
||||
}
|
||||
|
||||
boardMember := &model.BoardMember{
|
||||
BoardID: board.ID,
|
||||
UserID: "test-user",
|
||||
}
|
||||
|
||||
t.Run("Needs template init", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().GetTemplateBoards(model.GlobalTeamID, "").Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().RemoveDefaultTemplates([]*model.Board{}).Return(nil)
|
||||
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.Any(), gomock.Any()).AnyTimes().Return(boardsAndBlocks, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(gomock.Any(), gomock.Any()).AnyTimes().Return(boardMember, nil)
|
||||
|
||||
th.FilesBackend.On("WriteFile", mock.Anything, mock.Anything).Return(int64(1), nil)
|
||||
|
||||
done, err := th.App.initializeTemplates()
|
||||
require.NoError(t, err, "initializeTemplates should not error")
|
||||
require.True(t, done, "initialization was needed")
|
||||
})
|
||||
|
||||
t.Run("Skip template init", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
th.Store.EXPECT().GetTemplateBoards(model.GlobalTeamID, "").Return([]*model.Board{board}, nil)
|
||||
|
||||
done, err := th.App.initializeTemplates()
|
||||
require.NoError(t, err, "initializeTemplates should not error")
|
||||
require.False(t, done, "initialization was not needed")
|
||||
})
|
||||
}
|
||||
85
server/boards/app/user.go
Обычный файл
85
server/boards/app/user.go
Обычный файл
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func (a *App) GetTeamUsers(teamID string, asGuestID string) ([]*model.User, error) {
|
||||
return a.store.GetUsersByTeam(teamID, asGuestID, a.config.ShowEmailAddress, a.config.ShowFullName)
|
||||
}
|
||||
|
||||
func (a *App) SearchTeamUsers(teamID string, searchQuery string, asGuestID string, excludeBots bool) ([]*model.User, error) {
|
||||
users, err := a.store.SearchUsersByTeam(teamID, searchQuery, asGuestID, excludeBots, a.config.ShowEmailAddress, a.config.ShowFullName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, u := range users {
|
||||
if a.permissions.HasPermissionToTeam(u.ID, teamID, model.PermissionManageTeam) {
|
||||
users[i].Permissions = append(users[i].Permissions, model.PermissionManageTeam.Id)
|
||||
}
|
||||
if a.permissions.HasPermissionTo(u.ID, model.PermissionManageSystem) {
|
||||
users[i].Permissions = append(users[i].Permissions, model.PermissionManageSystem.Id)
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateUserConfig(userID string, patch model.UserPreferencesPatch) ([]mm_model.Preference, error) {
|
||||
updatedPreferences, err := a.store.PatchUserPreferences(userID, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedPreferences, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUserPreferences(userID string) ([]mm_model.Preference, error) {
|
||||
return a.store.GetUserPreferences(userID)
|
||||
}
|
||||
|
||||
func (a *App) UserIsGuest(userID string) (bool, error) {
|
||||
user, err := a.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return user.IsGuest, nil
|
||||
}
|
||||
|
||||
func (a *App) CanSeeUser(seerUser string, seenUser string) (bool, error) {
|
||||
isGuest, err := a.UserIsGuest(seerUser)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if isGuest {
|
||||
hasSharedChannels, err := a.store.CanSeeUser(seerUser, seenUser)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hasSharedChannels, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) SearchUserChannels(teamID string, userID string, query string) ([]*mm_model.Channel, error) {
|
||||
channels, err := a.store.SearchUserChannels(teamID, userID, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var writeableChannels []*mm_model.Channel
|
||||
for _, channel := range channels {
|
||||
if a.permissions.HasPermissionToChannel(userID, channel.Id, model.PermissionCreatePost) {
|
||||
writeableChannels = append(writeableChannels, channel)
|
||||
}
|
||||
}
|
||||
return writeableChannels, nil
|
||||
}
|
||||
|
||||
func (a *App) GetChannel(teamID string, channelID string) (*mm_model.Channel, error) {
|
||||
return a.store.GetChannel(teamID, channelID)
|
||||
}
|
||||
89
server/boards/app/user_test.go
Обычный файл
89
server/boards/app/user_test.go
Обычный файл
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func TestSearchUsers(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
th.App.config.ShowEmailAddress = false
|
||||
th.App.config.ShowFullName = false
|
||||
|
||||
teamID := "team-id-1"
|
||||
userID := "user-id-1"
|
||||
|
||||
t.Run("return empty users", func(t *testing.T) {
|
||||
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{}, nil)
|
||||
|
||||
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(users))
|
||||
})
|
||||
|
||||
t.Run("return user", func(t *testing.T) {
|
||||
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
|
||||
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
|
||||
|
||||
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(users))
|
||||
assert.Equal(t, 0, len(users[0].Permissions))
|
||||
})
|
||||
|
||||
t.Run("return team admin", func(t *testing.T) {
|
||||
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
th.App.config.ShowEmailAddress = false
|
||||
th.App.config.ShowFullName = false
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
|
||||
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
|
||||
|
||||
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(users))
|
||||
assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id)
|
||||
})
|
||||
|
||||
t.Run("return system admin", func(t *testing.T) {
|
||||
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
|
||||
th.App.config.ShowEmailAddress = false
|
||||
th.App.config.ShowFullName = false
|
||||
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
|
||||
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(true).Times(1)
|
||||
|
||||
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(users))
|
||||
assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id)
|
||||
assert.Equal(t, users[0].Permissions[1], model.PermissionManageSystem.Id)
|
||||
})
|
||||
|
||||
t.Run("test user channels", func(t *testing.T) {
|
||||
channelID := "Channel1"
|
||||
th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mm_model.Channel{{Id: channelID}}, nil)
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1)
|
||||
|
||||
channels, err := th.App.SearchUserChannels(teamID, userID, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(channels))
|
||||
})
|
||||
|
||||
t.Run("test user channels- no permissions", func(t *testing.T) {
|
||||
channelID := "Channel1"
|
||||
th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mm_model.Channel{{Id: channelID}}, nil)
|
||||
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
|
||||
|
||||
channels, err := th.App.SearchUserChannels(teamID, userID, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(channels))
|
||||
})
|
||||
}
|
||||
Ссылка в новой задаче
Block a user