- controller golem15.user.organisations behind golem15.users.access_users - list and form (name, slug preset from the name, description, avatar) - members relation manager: link sets and unlink clears users.organisation_id - deleting an organisation releases its members first - Organisation gains Fillable, Rules, MorphName, AttachRelations, BeforeValidate and the Members relation; models.Slugify - navigation side item organisations - TestAdminOrganisations, TestAdminOrganisationMembers, TestSlugify
483 lines
20 KiB
Go
483 lines
20 KiB
Go
package user
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
)
|
|
|
|
// adminOrganisationsPath is the Organisations controller under the admin API.
|
|
const adminOrganisationsPath = "/golem15/user/organisations"
|
|
|
|
// organisationPath is the admin API path of one organisation, with an
|
|
// optional suffix such as "/relations/members".
|
|
func organisationPath(id uint, suffix string) string {
|
|
return adminAPI(adminOrganisationsPath) + "/" + strconv.FormatUint(uint64(id), 10) + suffix
|
|
}
|
|
|
|
// organisation reads an organisation row; nil when it is gone.
|
|
func (e *adminEnv) organisation(t *testing.T, id uint) *models.Organisation {
|
|
t.Helper()
|
|
var rows []models.Organisation
|
|
if err := e.gdb.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
return &rows[0]
|
|
}
|
|
|
|
// seedOrganisation stores an organisation with a unique slug.
|
|
func (e *adminEnv) seedOrganisation(t *testing.T, name string) *models.Organisation {
|
|
t.Helper()
|
|
organisation := &models.Organisation{Name: name, Slug: models.Slugify(name)}
|
|
if err := e.gdb.Create(organisation).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return organisation
|
|
}
|
|
|
|
// TestSlugify pins the slug rule the organisation model shares with the
|
|
// admin form's slug preset.
|
|
func TestSlugify(t *testing.T) {
|
|
for text, want := range map[string]string{
|
|
"Acme Records": "acme-records",
|
|
" Acme -- Records ": "acme-records",
|
|
"ACME_Records 2": "acme-records-2",
|
|
"Zażółć gęślą": "za-g-l",
|
|
"---": "",
|
|
"": "",
|
|
"a": "a",
|
|
} {
|
|
if got := models.Slugify(text); got != want {
|
|
t.Fatalf("Slugify(%q) = %q, want %q", text, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAdminOrganisations covers the Organisations screen (D-22, D-20): its
|
|
// permission, the list, the form with the slug rules and the avatar, and the
|
|
// delete that releases the members.
|
|
func TestAdminOrganisations(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
groupsOnly := env.admin(t, permAccessGroups)
|
|
both := env.admin(t, permAccessUsers, permAccessGroups)
|
|
stamp := adminStamp()
|
|
|
|
// The screen needs golem15.users.access_users.
|
|
for _, path := range []string{adminAPI(adminOrganisationsPath), adminAPI(adminOrganisationsPath + "/schema/list"), adminAPI(adminOrganisationsPath + "/schema/form")} {
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, groupsOnly, nil)
|
|
if code := adminDecode(t, rec).Error.Code; code != "forbidden" {
|
|
t.Fatalf("%s code = %q", path, code)
|
|
}
|
|
}
|
|
env.expect(t, http.StatusForbidden, http.MethodPost, adminAPI(adminOrganisationsPath), groupsOnly, map[string]any{"name": "Nope " + stamp})
|
|
// All three screens are side items of `user`, in this order, each shown
|
|
// only with its permission.
|
|
if got := env.sideMenu(t, both); !reflect.DeepEqual(got, []string{
|
|
"users=golem15.user.users", "usergroups=golem15.user.usergroups", "organisations=golem15.user.organisations",
|
|
}) {
|
|
t.Fatalf("side menu with both permissions = %v", got)
|
|
}
|
|
if got := env.sideMenu(t, groupsOnly); !reflect.DeepEqual(got, []string{"usergroups=golem15.user.usergroups"}) {
|
|
t.Fatalf("side menu with the groups permission = %v", got)
|
|
}
|
|
|
|
// The list schema: Name, Slug and Created; rows open the update form.
|
|
var schema struct {
|
|
RecordURL string `json:"recordUrl"`
|
|
ShowCheckboxes bool `json:"showCheckboxes"`
|
|
ToolbarButtons []string `json:"toolbarButtons"`
|
|
Columns []struct {
|
|
Key string `json:"key"`
|
|
Invisible bool `json:"invisible"`
|
|
Searchable bool `json:"searchable"`
|
|
Type string `json:"type"`
|
|
} `json:"columns"`
|
|
Messages map[string]map[string]string `json:"messages"`
|
|
}
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/list"), token, nil), &schema)
|
|
var visible []string
|
|
for _, column := range schema.Columns {
|
|
if !column.Invisible {
|
|
visible = append(visible, column.Key)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(visible, []string{"name", "slug", "created_at"}) || schema.RecordURL != "golem15/user/organisations/update/:id" ||
|
|
schema.ShowCheckboxes || !reflect.DeepEqual(schema.ToolbarButtons, []string{"create"}) {
|
|
t.Fatalf("list schema = %+v", schema)
|
|
}
|
|
if got := schema.Messages["empty"]["other"]; got != "No organisations yet. Create the first one with New Organisation." {
|
|
t.Fatalf("empty message = %q", got)
|
|
}
|
|
if got := schema.Messages["create"]["other"]; got != "New Organisation" {
|
|
t.Fatalf("create message = %q", got)
|
|
}
|
|
|
|
// The form: name, slug (slug preset from name), description, the avatar
|
|
// as a 120 by 120 image and the members manager on update only; no
|
|
// preview.
|
|
var form struct {
|
|
Fields []struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Mode string `json:"mode"`
|
|
Tab string `json:"tab"`
|
|
Context any `json:"context"`
|
|
ImageWidth int `json:"imageWidth"`
|
|
ImageHeight int `json:"imageHeight"`
|
|
Preset *struct {
|
|
Field string `json:"field"`
|
|
Type string `json:"type"`
|
|
} `json:"preset"`
|
|
} `json:"fields"`
|
|
Preview json.RawMessage `json:"preview"`
|
|
}
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/form"), token, nil), &form)
|
|
var names []string
|
|
for _, field := range form.Fields {
|
|
names = append(names, field.Name+":"+field.Type)
|
|
switch field.Name {
|
|
case "slug":
|
|
if field.Preset == nil || field.Preset.Field != "name" || field.Preset.Type != "slug" {
|
|
t.Fatalf("slug preset = %+v", field.Preset)
|
|
}
|
|
case "avatar":
|
|
if field.Mode != "image" || field.ImageWidth != 120 || field.ImageHeight != 120 {
|
|
t.Fatalf("avatar field = %+v", field)
|
|
}
|
|
case "members":
|
|
if field.Context != "update" || field.Tab != "Members" {
|
|
t.Fatalf("members field = %+v", field)
|
|
}
|
|
}
|
|
}
|
|
if want := []string{"name:text", "slug:text", "description:textarea", "avatar:fileupload", "members:relation-manager"}; !reflect.DeepEqual(names, want) {
|
|
t.Fatalf("form fields = %v, want %v", names, want)
|
|
}
|
|
if len(form.Preview) != 0 && string(form.Preview) != "null" {
|
|
t.Fatalf("the organisation form has a preview: %s", form.Preview)
|
|
}
|
|
|
|
// Create: an empty slug is derived from the name; a given slug is kept.
|
|
create := func(status int, body map[string]any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return env.expect(t, status, http.MethodPost, adminAPI(adminOrganisationsPath), token, body)
|
|
}
|
|
name := "Acme Records " + stamp
|
|
id := createdID(t, create(http.StatusCreated, map[string]any{"name": name, "slug": "", "description": "A label"}))
|
|
if got := env.organisation(t, id); got == nil || got.Name != name || got.Slug != "acme-records-"+stamp || got.Description == nil || *got.Description != "A label" {
|
|
t.Fatalf("created organisation = %+v", got)
|
|
}
|
|
omitted := createdID(t, create(http.StatusCreated, map[string]any{"name": "Omitted Slug " + stamp}))
|
|
if got := env.organisation(t, omitted); got.Slug != "omitted-slug-"+stamp {
|
|
t.Fatalf("slug of a create without the key = %q", got.Slug)
|
|
}
|
|
given := createdID(t, create(http.StatusCreated, map[string]any{"name": "Given " + stamp, "slug": "Given_Slug-" + stamp}))
|
|
if got := env.organisation(t, given); got.Slug != "Given_Slug-"+stamp {
|
|
t.Fatalf("given slug = %q", got.Slug)
|
|
}
|
|
// A slug with other characters, a duplicate slug and a missing name are
|
|
// refused on their field and create nothing.
|
|
for _, bad := range []string{"has space " + stamp, "dot." + stamp, "ślad-" + stamp} {
|
|
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Bad " + stamp, "slug": bad})
|
|
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) != 1 || details["slug"][0] != "The slug may only contain letters, numbers, dashes and underscores." {
|
|
t.Fatalf("slug refusal for %q = %s", bad, rec.Body.String())
|
|
}
|
|
}
|
|
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Duplicate " + stamp, "slug": "acme-records-" + stamp})
|
|
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) == 0 {
|
|
t.Fatalf("duplicate slug refusal = %s", rec.Body.String())
|
|
}
|
|
// The derived slug collides as well.
|
|
rec = create(http.StatusUnprocessableEntity, map[string]any{"name": name})
|
|
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) == 0 {
|
|
t.Fatalf("derived duplicate slug refusal = %s", rec.Body.String())
|
|
}
|
|
rec = create(http.StatusUnprocessableEntity, map[string]any{"slug": "nameless-" + stamp})
|
|
if details := adminDecode(t, rec).Error.Details; len(details["name"]) == 0 {
|
|
t.Fatalf("missing name refusal = %s", rec.Body.String())
|
|
}
|
|
var count int64
|
|
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_organisations WHERE name LIKE ?`, "%"+stamp).Scan(&count).Error; err != nil || count != 3 {
|
|
t.Fatalf("organisations after the refused creates = %d (%v)", count, err)
|
|
}
|
|
|
|
// The list finds it by name and by slug.
|
|
for _, term := range []string{"Acme+Records+" + stamp, "acme-records-" + stamp} {
|
|
var rows []map[string]any
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath)+"?search="+term, token, nil), &rows)
|
|
if len(rows) != 1 || rows[0]["name"] != name || rows[0]["slug"] != "acme-records-"+stamp {
|
|
t.Fatalf("list for %q = %v", term, rows)
|
|
}
|
|
}
|
|
|
|
// Update: the fields change; a bad slug is refused and changes nothing.
|
|
env.expect(t, http.StatusOK, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "acme-" + stamp, "description": "Renamed slug"})
|
|
if got := env.organisation(t, id); got.Slug != "acme-"+stamp || *got.Description != "Renamed slug" {
|
|
t.Fatalf("updated organisation = %+v", got)
|
|
}
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "acme " + stamp})
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "Given_Slug-" + stamp})
|
|
if got := env.organisation(t, id); got.Slug != "acme-"+stamp {
|
|
t.Fatalf("a refused update changed the slug to %q", got.Slug)
|
|
}
|
|
|
|
// Avatar: a file uploaded through the admin file route is attached to
|
|
// the organisation under its morph name when the form is saved.
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
part, err := mw.CreateFormFile("file_data", "logo.jpg")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write(jpegBytes(t)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sessionKey := adminSessionKey(t)
|
|
headers := map[string]string{cabana.SessionKeyHeader: sessionKey}
|
|
if up := env.send(t, http.MethodPost, organisationPath(id, "/files/avatar"), token, buf.Bytes(), mw.FormDataContentType(), headers); up.Code != http.StatusCreated && up.Code != http.StatusOK {
|
|
t.Fatalf("avatar upload = %d %s", up.Code, up.Body.String())
|
|
}
|
|
body, err := json.Marshal(map[string]any{"name": name, "slug": "acme-" + stamp})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if saved := env.send(t, http.MethodPut, organisationPath(id, ""), token, body, "application/json", headers); saved.Code != http.StatusOK {
|
|
t.Fatalf("save with the avatar = %d %s", saved.Code, saved.Body.String())
|
|
}
|
|
files := func() int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := env.gdb.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`,
|
|
`Golem15\User\Models\Organisation`, strconv.FormatUint(uint64(id), 10)).Scan(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
if files() != 1 || (models.Organisation{}).MorphName() != `Golem15\User\Models\Organisation` {
|
|
t.Fatalf("avatar rows of the organisation = %d", files())
|
|
}
|
|
|
|
// Delete: an organisation with members goes; its members stay, active or
|
|
// deactivated, without an organisation and without a role in it. Users
|
|
// of another organisation are not touched.
|
|
role := "owner"
|
|
owner := env.seedUser(t, "org-owner", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &id, &role })
|
|
gone := env.seedUser(t, "org-gone", func(u *models.User) { u.OrganisationID = &id })
|
|
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other := env.seedUser(t, "org-other", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &given, &role })
|
|
group := env.seedGroup(t, "Org "+stamp, "org-"+stamp)
|
|
env.join(t, owner, group)
|
|
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(id, ""), token, nil)
|
|
if env.organisation(t, id) != nil {
|
|
t.Fatal("the organisation survived its delete")
|
|
}
|
|
for _, member := range []*models.User{owner, gone} {
|
|
got := env.reload(t, member.ID)
|
|
if got == nil || got.OrganisationID != nil || got.OrganisationRole != nil || got.Email != member.Email {
|
|
t.Fatalf("member %d after the delete = %+v", member.ID, got)
|
|
}
|
|
}
|
|
if got := env.reload(t, other.ID); got.OrganisationID == nil || *got.OrganisationID != given || got.OrganisationRole == nil {
|
|
t.Fatalf("a member of another organisation changed: %+v", got)
|
|
}
|
|
if got := env.groupIDs(t, owner.ID); !reflect.DeepEqual(got, []uint{group.ID}) {
|
|
t.Fatalf("the delete changed the owner's groups: %v", got)
|
|
}
|
|
env.expect(t, http.StatusNotFound, http.MethodGet, organisationPath(id, ""), token, nil)
|
|
}
|
|
|
|
// TestAdminOrganisationMembers covers the members relation manager: link
|
|
// sets and unlink clears users.organisation_id, the lists are scoped to the
|
|
// organisation, and nothing else of a user changes, least of all
|
|
// users_groups (T-12.1-30).
|
|
func TestAdminOrganisationMembers(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
groupsOnly := env.admin(t, permAccessGroups)
|
|
stamp := adminStamp()
|
|
here := env.seedOrganisation(t, "Here "+stamp)
|
|
there := env.seedOrganisation(t, "There "+stamp)
|
|
label := "Member" + stamp
|
|
named := func(suffix string, mutate func(*models.User)) *models.User {
|
|
return env.seedUser(t, "member-"+suffix, func(u *models.User) {
|
|
name := label + " " + suffix
|
|
u.Name = &name
|
|
if mutate != nil {
|
|
mutate(u)
|
|
}
|
|
})
|
|
}
|
|
first, second := named("first", nil), named("second", nil)
|
|
role := "member"
|
|
foreign := named("foreign", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &there.ID, &role })
|
|
group := env.seedGroup(t, "Members "+stamp, "members-"+stamp)
|
|
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
|
|
env.join(t, first, group, adminGroup)
|
|
env.join(t, second, group)
|
|
env.join(t, foreign, adminGroup)
|
|
everyone := []*models.User{first, second, foreign}
|
|
memberships := map[uint][]uint{}
|
|
stored := map[uint]*models.User{}
|
|
for _, user := range everyone {
|
|
memberships[user.ID] = env.groupIDs(t, user.ID)
|
|
stored[user.ID] = env.reload(t, user.ID)
|
|
}
|
|
untouched := func() {
|
|
t.Helper()
|
|
for _, user := range everyone {
|
|
if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, memberships[user.ID]) {
|
|
t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, memberships[user.ID])
|
|
}
|
|
got, want := env.reload(t, user.ID), stored[user.ID]
|
|
if got == nil || got.Email != want.Email || got.Password != want.Password || got.IsActivated != want.IsActivated ||
|
|
!reflect.DeepEqual(got.Name, want.Name) || !reflect.DeepEqual(got.OrganisationRole, want.OrganisationRole) ||
|
|
!reflect.DeepEqual(got.TokensValidAfter, want.TokensValidAfter) || got.DeletedAt.Valid {
|
|
t.Fatalf("user %d changed beyond organisation_id: %+v", user.ID, got)
|
|
}
|
|
}
|
|
}
|
|
organisationOf := func(user *models.User) uint {
|
|
t.Helper()
|
|
got := env.reload(t, user.ID)
|
|
if got == nil || got.OrganisationID == nil {
|
|
return 0
|
|
}
|
|
return *got.OrganisationID
|
|
}
|
|
ids := func(path string) []uint {
|
|
t.Helper()
|
|
var rows []map[string]any
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, path, token, nil), &rows)
|
|
out := []uint{}
|
|
for _, row := range rows {
|
|
out = append(out, uint(row["id"].(float64)))
|
|
}
|
|
return out
|
|
}
|
|
members := func(organisation *models.Organisation) []uint {
|
|
t.Helper()
|
|
return ids(organisationPath(organisation.ID, "/relations/members"))
|
|
}
|
|
candidates := func() []uint {
|
|
t.Helper()
|
|
return ids(organisationPath(here.ID, "/relations/members/candidates?search="+label))
|
|
}
|
|
mutate := func(organisation *models.Organisation, action string, users ...*models.User) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
list := make([]uint, len(users))
|
|
for i, user := range users {
|
|
list[i] = user.ID
|
|
}
|
|
return env.call(t, http.MethodPost, organisationPath(organisation.ID, "/relations/members/"+action), token, map[string]any{"ids": list})
|
|
}
|
|
|
|
// The relation schema carries the screen's copy: the unlink confirm
|
|
// counts and says the users are not deleted.
|
|
var relation struct {
|
|
Messages map[string]map[string]string `json:"messages"`
|
|
}
|
|
rawRelation := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/relation/members"), token, nil)
|
|
adminInto(t, rawRelation, &relation)
|
|
for key, want := range map[string]string{
|
|
"link": "Add member",
|
|
"unlinkSelected": "Remove selected",
|
|
"unlinkConfirm": "Remove the selected (:count) from this organisation? The users are not deleted.",
|
|
"empty": "This organisation has no members yet. Add users with Add member.",
|
|
} {
|
|
if got := relation.Messages[key]["other"]; got != want {
|
|
t.Fatalf("relation message %s = %q, want %q (%s)", key, got, want, rawRelation.Body.String())
|
|
}
|
|
}
|
|
env.expect(t, http.StatusNotFound, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/relation/groups"), token, nil)
|
|
|
|
// The routes need the screen's permission.
|
|
env.expect(t, http.StatusForbidden, http.MethodGet, organisationPath(here.ID, "/relations/members"), groupsOnly, nil)
|
|
env.expect(t, http.StatusForbidden, http.MethodPost, organisationPath(here.ID, "/relations/members/link"), groupsOnly, map[string]any{"ids": []uint{first.ID}})
|
|
if organisationOf(first) != 0 {
|
|
t.Fatal("a refused link set organisation_id")
|
|
}
|
|
|
|
// An organisation without members lists none; the candidates are the
|
|
// users without an organisation.
|
|
if got := members(here); len(got) != 0 {
|
|
t.Fatalf("members of a new organisation = %v", got)
|
|
}
|
|
if got := candidates(); !reflect.DeepEqual(got, []uint{first.ID, second.ID}) {
|
|
t.Fatalf("candidates = %v, want %v", got, []uint{first.ID, second.ID})
|
|
}
|
|
|
|
// Link sets organisation_id of both users.
|
|
if rec := mutate(here, "link", first, second); rec.Code != http.StatusOK {
|
|
t.Fatalf("link = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if organisationOf(first) != here.ID || organisationOf(second) != here.ID {
|
|
t.Fatalf("organisation_id after the link = %d, %d", organisationOf(first), organisationOf(second))
|
|
}
|
|
untouched()
|
|
// Each list shows only its own organisation's members.
|
|
if got := members(here); !reflect.DeepEqual(got, []uint{first.ID, second.ID}) {
|
|
t.Fatalf("members here = %v", got)
|
|
}
|
|
if got := members(there); !reflect.DeepEqual(got, []uint{foreign.ID}) {
|
|
t.Fatalf("members there = %v", got)
|
|
}
|
|
if got := candidates(); len(got) != 0 {
|
|
t.Fatalf("candidates after the link = %v", got)
|
|
}
|
|
|
|
// A member of another organisation is not a candidate and cannot be
|
|
// taken over by a link: they stay where they are.
|
|
refused := mutate(here, "link", foreign)
|
|
if details := adminDecode(t, refused).Error.Details; refused.Code != http.StatusUnprocessableEntity || len(details["ids"]) == 0 {
|
|
t.Fatalf("link of a foreign member = %d %s", refused.Code, refused.Body.String())
|
|
}
|
|
if organisationOf(foreign) != there.ID {
|
|
t.Fatalf("a foreign member moved to %d", organisationOf(foreign))
|
|
}
|
|
// Unlinking through the wrong organisation is ignored.
|
|
mutate(here, "unlink", foreign)
|
|
if organisationOf(foreign) != there.ID {
|
|
t.Fatal("an unlink through another organisation cleared organisation_id")
|
|
}
|
|
untouched()
|
|
|
|
// Unlink clears organisation_id of the chosen user only; the user stays.
|
|
if rec := mutate(here, "unlink", first); rec.Code != http.StatusOK {
|
|
t.Fatalf("unlink = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if organisationOf(first) != 0 || organisationOf(second) != here.ID || env.reload(t, first.ID) == nil {
|
|
t.Fatalf("organisation_id after the unlink = %d, %d", organisationOf(first), organisationOf(second))
|
|
}
|
|
if got := members(here); !reflect.DeepEqual(got, []uint{second.ID}) {
|
|
t.Fatalf("members after the unlink = %v", got)
|
|
}
|
|
untouched()
|
|
|
|
// Once released there, the user can be linked here: this is how a user
|
|
// moves between organisations.
|
|
if rec := mutate(there, "unlink", foreign); rec.Code != http.StatusOK {
|
|
t.Fatalf("unlink there = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := mutate(here, "link", foreign); rec.Code != http.StatusOK || organisationOf(foreign) != here.ID {
|
|
t.Fatalf("link after the release = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
untouched()
|
|
}
|