61a84bfeae
- POST /guilds/{id}/invites (CREATE_INVITES): код из 10 символов, лимит
использований и срок жизни; GET /guilds/{id}/invites (MANAGE_GUILD);
DELETE /invites/{code} — создатель или MANAGE_GUILD;
- GET /invites/{code} — предпросмотр с карточкой сервера, 410 для истёкших и
исчерпанных; POST /invites/{code} — принятие с проверкой лимита участников,
повторное принятие не расходует использование;
- PATCH /guilds/{id} принимает `public`: сервер попадает в каталог и открыт для
входа без приглашения (главный сервер публиковать нельзя);
- GET /guilds/public — каталог (имя, описание, иконка, число участников);
- аудит invite.create/invite.revoke/invite.accept, события INVITE_CREATE и
INVITE_DELETE владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
публичный каталог и вход в публичный сервер.
174 lines
7.2 KiB
Go
174 lines
7.2 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
func TestInviteLifecycle(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
ownerCookie := registerAndLogin(t, srv, "inv_owner", "inv-owner@example.com")
|
|
guestCookie := registerAndLogin(t, srv, "inv_guest", "inv-guest@example.com")
|
|
|
|
// Первый сервер становится главным (в него может войти кто угодно),
|
|
// приглашения проверяем на втором — закрытом.
|
|
doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie)
|
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Закрытый"}`, ownerCookie)
|
|
guild := decodeResponse[struct {
|
|
Guild struct {
|
|
ID string `json:"id"`
|
|
} `json:"guild"`
|
|
}](t, created)
|
|
|
|
// Посторонний не видит закрытый сервер.
|
|
hidden := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID, "", guestCookie)
|
|
if hidden.Code != http.StatusNotFound {
|
|
t.Fatalf("guest guild access = %d, want 404", hidden.Code)
|
|
}
|
|
|
|
// Приглашение с лимитом в одно использование.
|
|
inviteRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/invites",
|
|
`{"max_uses":1,"max_age_seconds":3600}`, ownerCookie)
|
|
if inviteRec.Code != http.StatusOK {
|
|
t.Fatalf("create invite = %d, body = %s", inviteRec.Code, inviteRec.Body.String())
|
|
}
|
|
invite := decodeResponse[struct {
|
|
Invite struct {
|
|
Code string `json:"code"`
|
|
URL string `json:"url"`
|
|
} `json:"invite"`
|
|
Guild struct {
|
|
Name string `json:"name"`
|
|
MemberCount int `json:"member_count"`
|
|
IsMember bool `json:"is_member"`
|
|
} `json:"guild"`
|
|
}](t, inviteRec)
|
|
if len(invite.Invite.Code) != 10 {
|
|
t.Fatalf("invite code = %q, want 10 символов", invite.Invite.Code)
|
|
}
|
|
// В ответе на создание IsMember отражает самого создателя (он в сервере).
|
|
if invite.Guild.Name != "Закрытый" || !invite.Guild.IsMember {
|
|
t.Fatalf("unexpected preview: %+v", invite.Guild)
|
|
}
|
|
|
|
// Предпросмотр доступен гостю и показывает сервер.
|
|
preview := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
|
|
if preview.Code != http.StatusOK {
|
|
t.Fatalf("preview = %d, body = %s", preview.Code, preview.Body.String())
|
|
}
|
|
previewPayload := decodeResponse[struct {
|
|
Guild struct {
|
|
Name string `json:"name"`
|
|
MemberCount int `json:"member_count"`
|
|
IsMember bool `json:"is_member"`
|
|
} `json:"guild"`
|
|
}](t, preview)
|
|
if previewPayload.Guild.Name != "Закрытый" || previewPayload.Guild.IsMember || previewPayload.Guild.MemberCount != 1 {
|
|
t.Fatalf("guest preview = %+v", previewPayload.Guild)
|
|
}
|
|
|
|
// Принятие: гость становится участником.
|
|
accept := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
|
|
if accept.Code != http.StatusOK {
|
|
t.Fatalf("accept invite = %d, body = %s", accept.Code, accept.Body.String())
|
|
}
|
|
member := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID, "", guestCookie)
|
|
if member.Code != http.StatusOK {
|
|
t.Fatalf("guild after invite = %d, body = %s", member.Code, member.Body.String())
|
|
}
|
|
|
|
// Повторное принятие тем же пользователем не расходует использования.
|
|
again := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
|
|
if again.Code != http.StatusOK {
|
|
t.Fatalf("repeat accept = %d, body = %s", again.Code, again.Body.String())
|
|
}
|
|
|
|
// Третьего участника приглашение уже не пускает: лимит исчерпан.
|
|
thirdCookie := registerAndLogin(t, srv, "inv_third", "inv-third@example.com")
|
|
exhausted := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", thirdCookie)
|
|
if exhausted.Code != http.StatusGone {
|
|
t.Fatalf("exhausted invite = %d, want 410", exhausted.Code)
|
|
}
|
|
|
|
// Отзыв приглашения владельцем и проверка списка.
|
|
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/invites", "", ownerCookie)
|
|
invites := decodeResponse[struct {
|
|
Invites []struct {
|
|
Code string `json:"code"`
|
|
Uses int `json:"uses"`
|
|
} `json:"invites"`
|
|
}](t, list)
|
|
if len(invites.Invites) != 1 || invites.Invites[0].Uses != 1 {
|
|
t.Fatalf("invites = %+v", invites.Invites)
|
|
}
|
|
revoke := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+invite.Invite.Code, "", ownerCookie)
|
|
if revoke.Code != http.StatusOK {
|
|
t.Fatalf("revoke = %d, body = %s", revoke.Code, revoke.Body.String())
|
|
}
|
|
afterRevoke := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+invite.Invite.Code, "", guestCookie)
|
|
if afterRevoke.Code != http.StatusNotFound {
|
|
t.Fatalf("revoked invite = %d, want 404", afterRevoke.Code)
|
|
}
|
|
|
|
// Обычный участник без CREATE_INVITES приглашение создать не может.
|
|
memberInvite := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/invites", `{}`, guestCookie)
|
|
if memberInvite.Code != http.StatusForbidden {
|
|
t.Fatalf("member invite = %d, want 403", memberInvite.Code)
|
|
}
|
|
}
|
|
|
|
func TestPublicGuildsCatalog(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
ownerCookie := registerAndLogin(t, srv, "pub_owner", "pub-owner@example.com")
|
|
guestCookie := registerAndLogin(t, srv, "pub_guest", "pub-guest@example.com")
|
|
|
|
doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie)
|
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Публичный"}`, ownerCookie)
|
|
guild := decodeResponse[struct {
|
|
Guild struct {
|
|
ID string `json:"id"`
|
|
} `json:"guild"`
|
|
}](t, created)
|
|
|
|
// Пока сервер закрыт, в каталоге его нет.
|
|
closed := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/public", "", guestCookie)
|
|
closedList := decodeResponse[struct {
|
|
Guilds []struct {
|
|
Name string `json:"name"`
|
|
} `json:"guilds"`
|
|
}](t, closed)
|
|
for _, item := range closedList.Guilds {
|
|
if item.Name == "Публичный" {
|
|
t.Fatal("closed guild must not be in the public catalog")
|
|
}
|
|
}
|
|
|
|
// Делаем сервер публичным: появляется в каталоге и открыт для входа.
|
|
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guild.Guild.ID,
|
|
`{"public":true}`, ownerCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("publish guild = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
open := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/public", "", guestCookie)
|
|
openList := decodeResponse[struct {
|
|
Guilds []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
MemberCount int `json:"member_count"`
|
|
} `json:"guilds"`
|
|
}](t, open)
|
|
found := false
|
|
for _, item := range openList.Guilds {
|
|
if item.ID == guild.Guild.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("public guild is missing from the catalog: %+v", openList.Guilds)
|
|
}
|
|
|
|
join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", guestCookie)
|
|
if join.Code != http.StatusOK {
|
|
t.Fatalf("join public guild = %d, body = %s", join.Code, join.Body.String())
|
|
}
|
|
}
|