Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration.
391 lines
13 KiB
Go
391 lines
13 KiB
Go
package api_test
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
func createInvite(t *testing.T, e *testEnv, maxUses int, label string, expiresInDays *int) map[string]any {
|
|
t.Helper()
|
|
admin := login(t, e, bootstrapAdminEmail)
|
|
body := map[string]any{"maxUses": maxUses, "label": label}
|
|
if expiresInDays != nil {
|
|
body["expiresInDays"] = *expiresInDays
|
|
}
|
|
code, resp := e.do(t, "POST", "/api/admin/invites", admin, body)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("create invite: %d %v", code, resp)
|
|
}
|
|
inv, ok := resp["invite"].(map[string]any)
|
|
if !ok || inv["token"] == nil || inv["url"] == nil {
|
|
t.Fatalf("missing invite token/url: %v", resp)
|
|
}
|
|
return inv
|
|
}
|
|
|
|
func TestInviteRegisterSuccessWhenPublicDisabled(t *testing.T) {
|
|
e := setup(t)
|
|
inv := createInvite(t, e, 3, "دوره بهار", nil)
|
|
token := inv["token"].(string)
|
|
|
|
code, body := e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("invite status: %d %v", code, body)
|
|
}
|
|
if body["valid"] != true {
|
|
t.Fatalf("expected valid, got %v", body)
|
|
}
|
|
if _, ok := body["remaining"]; ok {
|
|
t.Fatalf("public status must not expose remaining: %v", body)
|
|
}
|
|
if _, ok := body["maxUses"]; ok {
|
|
t.Fatalf("public status must not expose maxUses: %v", body)
|
|
}
|
|
if _, ok := body["usedCount"]; ok {
|
|
t.Fatalf("public status must not expose usedCount: %v", body)
|
|
}
|
|
if body["label"] != "دوره بهار" {
|
|
t.Fatalf("label: %v", body["label"])
|
|
}
|
|
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.101",
|
|
}, registerBody(t, e, "09122220001", "inv-ok@test.dev", "invok"))
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("invite register: %d %v", code, body)
|
|
}
|
|
if body["accessToken"] == nil || body["user"] == nil {
|
|
t.Fatalf("missing session: %v", body)
|
|
}
|
|
|
|
code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("invite status after use: %d %v", code, body)
|
|
}
|
|
if body["valid"] != true {
|
|
t.Fatalf("expected still valid, got %v", body)
|
|
}
|
|
if _, ok := body["remaining"]; ok {
|
|
t.Fatalf("public status must not expose remaining after use: %v", body)
|
|
}
|
|
}
|
|
|
|
func TestInviteCapacityExhaustion(t *testing.T) {
|
|
e := setup(t)
|
|
inv := createInvite(t, e, 1, "", nil)
|
|
token := inv["token"].(string)
|
|
|
|
code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.110",
|
|
}, registerBody(t, e, "09122220010", "inv-full1@test.dev", "invfull1"))
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("first seat: %d %v", code, body)
|
|
}
|
|
|
|
code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil)
|
|
if code != http.StatusGone {
|
|
t.Fatalf("expected gone when full, got %d %v", code, body)
|
|
}
|
|
if body["error"] != "این لینک عضویت قابل استفاده نیست" {
|
|
t.Fatalf("expected generic message, got %v", body["error"])
|
|
}
|
|
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.111",
|
|
}, registerBody(t, e, "09122220011", "inv-full2@test.dev", "invfull2"))
|
|
if code != http.StatusGone {
|
|
t.Fatalf("expected capacity gone, got %d %v", code, body)
|
|
}
|
|
}
|
|
|
|
func TestInviteRevoke(t *testing.T) {
|
|
e := setup(t)
|
|
inv := createInvite(t, e, 5, "لغو", nil)
|
|
token := inv["token"].(string)
|
|
id := int64(inv["id"].(float64))
|
|
|
|
admin := login(t, e, bootstrapAdminEmail)
|
|
code, body := e.do(t, "POST", "/api/admin/invites/"+itoa(id)+"/revoke", admin, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("revoke: %d %v", code, body)
|
|
}
|
|
revoked := body["invite"].(map[string]any)
|
|
if revoked["status"] != "revoked" {
|
|
t.Fatalf("expected revoked, got %v", revoked["status"])
|
|
}
|
|
|
|
code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil)
|
|
if code != http.StatusGone {
|
|
t.Fatalf("expected gone after revoke, got %d %v", code, body)
|
|
}
|
|
if body["error"] != "این لینک عضویت قابل استفاده نیست" {
|
|
t.Fatalf("expected generic message, got %v", body["error"])
|
|
}
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.120",
|
|
}, registerBody(t, e, "09122220020", "inv-rev@test.dev", "invrev"))
|
|
if code != http.StatusGone {
|
|
t.Fatalf("expected register gone after revoke, got %d %v", code, body)
|
|
}
|
|
}
|
|
|
|
func TestInviteDuplicatePhoneAndIP(t *testing.T) {
|
|
e := setup(t)
|
|
inv := createInvite(t, e, 5, "", nil)
|
|
token := inv["token"].(string)
|
|
|
|
code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.130",
|
|
}, registerBody(t, e, "09122220030", "inv-dup1@test.dev", "invdup1"))
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("first: %d %v", code, body)
|
|
}
|
|
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.131",
|
|
}, registerBody(t, e, "09122220030", "inv-dup2@test.dev", "invdup2"))
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("expected phone conflict, got %d %v", code, body)
|
|
}
|
|
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.130",
|
|
}, registerBody(t, e, "09122220031", "inv-dup3@test.dev", "invdup3"))
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("expected IP conflict, got %d %v", code, body)
|
|
}
|
|
}
|
|
|
|
func TestInviteAdminListAndPublicStillWorks(t *testing.T) {
|
|
e := setup(t)
|
|
_ = createInvite(t, e, 2, "لیست", nil)
|
|
admin := login(t, e, bootstrapAdminEmail)
|
|
code, body := e.do(t, "GET", "/api/admin/invites", admin, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("list: %d %v", code, body)
|
|
}
|
|
list, ok := body["items"].([]any)
|
|
if !ok || len(list) < 1 {
|
|
t.Fatalf("expected invites list, got %v", body)
|
|
}
|
|
first := list[0].(map[string]any)
|
|
if first["token"] == nil || first["url"] == nil {
|
|
t.Fatalf("list should include token/url for copy: %v", first)
|
|
}
|
|
if first["remaining"] != float64(2) {
|
|
t.Fatalf("remaining: %v", first["remaining"])
|
|
}
|
|
|
|
enableRegistration(t, e)
|
|
whitelistPhones(t, e, "09122220040")
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.140",
|
|
}, registerBody(t, e, "09122220040", "pub-still@test.dev", "pubstill"))
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("public register should still work: %d %v", code, body)
|
|
}
|
|
}
|
|
|
|
func TestInviteBadToken(t *testing.T) {
|
|
e := setup(t)
|
|
code, body := e.do(t, "GET", "/api/auth/register-invite/not-a-real-token", "", nil)
|
|
if code != http.StatusNotFound {
|
|
t.Fatalf("expected not found, got %d %v", code, body)
|
|
}
|
|
}
|
|
|
|
func TestInvitePauseResume(t *testing.T) {
|
|
e := setup(t)
|
|
inv := createInvite(t, e, 5, "توقف", nil)
|
|
token := inv["token"].(string)
|
|
id := int64(inv["id"].(float64))
|
|
admin := login(t, e, bootstrapAdminEmail)
|
|
|
|
code, body := e.do(t, "POST", "/api/admin/invites/"+itoa(id)+"/pause", admin, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("pause: %d %v", code, body)
|
|
}
|
|
paused := body["invite"].(map[string]any)
|
|
if paused["status"] != "paused" {
|
|
t.Fatalf("expected paused, got %v", paused["status"])
|
|
}
|
|
if paused["remaining"] != float64(5) {
|
|
t.Fatalf("admin should still see remaining while paused: %v", paused["remaining"])
|
|
}
|
|
|
|
code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil)
|
|
if code != http.StatusGone {
|
|
t.Fatalf("expected gone while paused, got %d %v", code, body)
|
|
}
|
|
if body["error"] != "این لینک عضویت قابل استفاده نیست" {
|
|
t.Fatalf("expected generic message, got %v", body["error"])
|
|
}
|
|
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.150",
|
|
}, registerBody(t, e, "09122220050", "inv-pause@test.dev", "invpause"))
|
|
if code != http.StatusGone {
|
|
t.Fatalf("register while paused should fail: %d %v", code, body)
|
|
}
|
|
|
|
code, body = e.do(t, "POST", "/api/admin/invites/"+itoa(id)+"/resume", admin, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("resume: %d %v", code, body)
|
|
}
|
|
resumed := body["invite"].(map[string]any)
|
|
if resumed["status"] != "active" {
|
|
t.Fatalf("expected active after resume, got %v", resumed["status"])
|
|
}
|
|
|
|
code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil)
|
|
if code != http.StatusOK || body["valid"] != true {
|
|
t.Fatalf("expected valid after resume: %d %v", code, body)
|
|
}
|
|
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.151",
|
|
}, registerBody(t, e, "09122220051", "inv-resume@test.dev", "invresume"))
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("register after resume: %d %v", code, body)
|
|
}
|
|
}
|
|
|
|
func TestInviteEditCapacityAndLabel(t *testing.T) {
|
|
e := setup(t)
|
|
inv := createInvite(t, e, 3, "قدیمی", nil)
|
|
token := inv["token"].(string)
|
|
id := int64(inv["id"].(float64))
|
|
admin := login(t, e, bootstrapAdminEmail)
|
|
|
|
code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.160",
|
|
}, registerBody(t, e, "09122220060", "inv-edit1@test.dev", "invedit1"))
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("consume one seat: %d %v", code, body)
|
|
}
|
|
code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{
|
|
"X-Forwarded-For": "203.0.113.161",
|
|
}, registerBody(t, e, "09122220061", "inv-edit2@test.dev", "invedit2"))
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("consume second seat: %d %v", code, body)
|
|
}
|
|
|
|
code, body = e.do(t, "PUT", "/api/admin/invites/"+itoa(id), admin, map[string]any{
|
|
"label": "جدید",
|
|
"maxUses": 1,
|
|
})
|
|
if code != http.StatusBadRequest {
|
|
t.Fatalf("maxUses < usedCount should fail: %d %v", code, body)
|
|
}
|
|
|
|
code, body = e.do(t, "PUT", "/api/admin/invites/"+itoa(id), admin, map[string]any{
|
|
"label": "جدید",
|
|
"maxUses": 4,
|
|
"expiresInDays": 7,
|
|
})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("update invite: %d %v", code, body)
|
|
}
|
|
updated := body["invite"].(map[string]any)
|
|
if updated["label"] != "جدید" {
|
|
t.Fatalf("label: %v", updated["label"])
|
|
}
|
|
if updated["maxUses"] != float64(4) {
|
|
t.Fatalf("maxUses: %v", updated["maxUses"])
|
|
}
|
|
if updated["usedCount"] != float64(2) {
|
|
t.Fatalf("usedCount: %v", updated["usedCount"])
|
|
}
|
|
if updated["remaining"] != float64(2) {
|
|
t.Fatalf("remaining: %v", updated["remaining"])
|
|
}
|
|
if updated["expiresAt"] == nil {
|
|
t.Fatalf("expected expiresAt after update: %v", updated)
|
|
}
|
|
|
|
code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil)
|
|
if code != http.StatusOK || body["valid"] != true {
|
|
t.Fatalf("public still valid: %d %v", code, body)
|
|
}
|
|
if body["label"] != "جدید" {
|
|
t.Fatalf("public label: %v", body["label"])
|
|
}
|
|
if _, ok := body["maxUses"]; ok {
|
|
t.Fatalf("public must not expose capacity: %v", body)
|
|
}
|
|
|
|
code, body = e.do(t, "PUT", "/api/admin/invites/"+itoa(id), admin, map[string]any{
|
|
"clearExpires": true,
|
|
})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("clear expires: %d %v", code, body)
|
|
}
|
|
cleared := body["invite"].(map[string]any)
|
|
if cleared["expiresAt"] != nil {
|
|
t.Fatalf("expected nil expiresAt, got %v", cleared["expiresAt"])
|
|
}
|
|
}
|
|
|
|
func TestInvitePublicHidesCapacity(t *testing.T) {
|
|
e := setup(t)
|
|
inv := createInvite(t, e, 10, "محرمانه", nil)
|
|
token := inv["token"].(string)
|
|
|
|
code, body := e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status: %d %v", code, body)
|
|
}
|
|
for _, key := range []string{"remaining", "maxUses", "usedCount", "expiresAt"} {
|
|
if _, ok := body[key]; ok {
|
|
t.Fatalf("public payload must not include %s: %v", key, body)
|
|
}
|
|
}
|
|
if body["valid"] != true || body["label"] != "محرمانه" {
|
|
t.Fatalf("expected valid+label only essentials: %v", body)
|
|
}
|
|
}
|
|
|
|
func TestInviteListPaginationAndSearch(t *testing.T) {
|
|
e := setup(t)
|
|
admin := login(t, e, bootstrapAdminEmail)
|
|
|
|
for i := 1; i <= 12; i++ {
|
|
label := "عمومی"
|
|
if i == 3 {
|
|
label = "دوره پاییز ویژه"
|
|
}
|
|
_ = createInvite(t, e, 1, label, nil)
|
|
}
|
|
|
|
code, body := e.do(t, "GET", "/api/admin/invites?page=1&pageSize=5", admin, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("page1: %d %v", code, body)
|
|
}
|
|
if len(body["items"].([]any)) != 5 {
|
|
t.Fatalf("page1 len: %d", len(body["items"].([]any)))
|
|
}
|
|
if body["total"].(float64) < 12 || body["page"] != float64(1) || body["pageSize"] != float64(5) {
|
|
t.Fatalf("page1 meta: %v", body)
|
|
}
|
|
|
|
code, body = e.do(t, "GET", "/api/admin/invites?page=1", admin, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("default pageSize: %d %v", code, body)
|
|
}
|
|
if body["pageSize"] != float64(10) {
|
|
t.Fatalf("expected default pageSize 10, got %v", body["pageSize"])
|
|
}
|
|
|
|
code, body = e.do(t, "GET", "/api/admin/invites?page=1&pageSize=10&q=پاییز", admin, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("search: %d %v", code, body)
|
|
}
|
|
if body["total"] != float64(1) {
|
|
t.Fatalf("search total: %v", body["total"])
|
|
}
|
|
items := body["items"].([]any)
|
|
if len(items) != 1 || items[0].(map[string]any)["label"] != "دوره پاییز ویژه" {
|
|
t.Fatalf("search items: %v", items)
|
|
}
|
|
}
|