package api_test import ( "strings" "testing" ) func TestChatEditOwnMessage(t *testing.T) { e := setup(t) student, studentID := register(t, e, "Chat Editor", "chat-editor@test.dev") admin := login(t, e, bootstrapAdminEmail) code, me := e.do(t, "GET", "/api/auth/me", admin, nil) if code != 200 { t.Fatalf("me: %d %v", code, me) } adminID := int64(me["user"].(map[string]any)["id"].(float64)) code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "سلام"}) if code != 201 { t.Fatalf("send: %d %v", code, body) } msg := body["message"].(map[string]any) msgID := int64(msg["id"].(float64)) code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": "سلام ویرایش"}) if code != 200 { t.Fatalf("edit: %d %v", code, body) } edited := body["message"].(map[string]any) if edited["body"] != "سلام ویرایش" || edited["editedAt"] == nil { t.Fatalf("expected edited body, got %v", edited) } code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{"body": "نه"}) if code != 404 { t.Fatalf("partner edit should be rejected: %d", code) } code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": " "}) if code != 400 { t.Fatalf("empty edit: %d", code) } code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil) if code != 200 { t.Fatalf("list: %d %v", code, body) } msgs := body["messages"].([]any) last := msgs[len(msgs)-1].(map[string]any) if last["body"] != "سلام ویرایش" || last["editedAt"] == nil { t.Fatalf("listed message not edited: %v", last) } } func TestChatReplaceAttachment(t *testing.T) { e := setup(t) student, studentID := register(t, e, "Media Editor", "media-editor@test.dev") admin := login(t, e, bootstrapAdminEmail) code, me := e.do(t, "GET", "/api/auth/me", admin, nil) if code != 200 { t.Fatalf("me: %d %v", code, me) } adminID := int64(me["user"].(map[string]any)["id"].(float64)) png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00} code, up := uploadChatFile(t, e, student, "one.png", png) if code != 201 { t.Fatalf("upload: %d %v", code, up) } first := up["attachment"].(map[string]any) code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{ "body": "", "attachment": first, }) if code != 201 { t.Fatalf("send: %d %v", code, body) } msg := body["message"].(map[string]any) msgID := int64(msg["id"].(float64)) oldPath := mediaPath(msg["attachment"].(map[string]any)["url"]) png[8] = 0x01 code, up = uploadChatFile(t, e, student, "two.png", png) if code != 201 { t.Fatalf("upload replacement: %d %v", code, up) } next := up["attachment"].(map[string]any) code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ "body": "", "attachment": next, }) if code != 200 { t.Fatalf("replace: %d %v", code, body) } edited := body["message"].(map[string]any) newPath := mediaPath(edited["attachment"].(map[string]any)["url"]) if edited["editedAt"] == nil || newPath == "" || newPath == oldPath { t.Fatalf("expected a replaced attachment, old %s new %v", oldPath, edited) } if edited["attachment"].(map[string]any)["type"] != "image" { t.Fatalf("type: %v", edited["attachment"]) } wrong := map[string]any{"type": "video", "url": next["url"], "name": "clip.mp4", "size": next["size"]} code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ "body": "", "attachment": wrong, }) if code != 404 { t.Fatalf("different kind should miss: %d", code) } code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ "body": "", "attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1}, }) if code != 400 { t.Fatalf("file replace: %d", code) } code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{ "body": "", "attachment": next, }) if code != 400 { t.Fatalf("partner replace: %d", code) } } func TestChatReadMarksOnlyIncoming(t *testing.T) { e := setup(t) student, studentID := register(t, e, "Seen Student", "seen-student@test.dev") admin := login(t, e, bootstrapAdminEmail) code, me := e.do(t, "GET", "/api/auth/me", admin, nil) if code != 200 { t.Fatalf("me: %d %v", code, me) } adminID := int64(me["user"].(map[string]any)["id"].(float64)) code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "از هنرجو"}) if code != 201 { t.Fatalf("student send: %d %v", code, body) } studentMsg := int64(body["message"].(map[string]any)["id"].(float64)) code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil) if code != 200 { t.Fatalf("student list: %d %v", code, body) } if chatMsg(t, body, studentMsg)["readAt"] != nil { t.Fatalf("opening my own thread marked it seen: %v", chatMsg(t, body, studentMsg)) } code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil) if code != 200 { t.Fatalf("admin list: %d %v", code, body) } if chatMsg(t, body, studentMsg)["readAt"] == nil { t.Fatalf("partner open should mark the incoming message seen") } code, body = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", admin, map[string]any{"body": "از ادمین"}) if code != 201 { t.Fatalf("admin send: %d %v", code, body) } adminMsg := int64(body["message"].(map[string]any)["id"].(float64)) code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil) if code != 200 { t.Fatalf("admin list again: %d %v", code, body) } if chatMsg(t, body, adminMsg)["readAt"] != nil { t.Fatalf("admin own message was marked seen: %v", chatMsg(t, body, adminMsg)) } if chatMsg(t, body, studentMsg)["readAt"] == nil { t.Fatalf("already seen incoming message lost its receipt") } } func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any { t.Helper() for _, raw := range body["messages"].([]any) { msg := raw.(map[string]any) if int64(msg["id"].(float64)) == id { return msg } } t.Fatalf("message %d missing in %v", id, body["messages"]) return nil } func mediaPath(raw any) string { s, _ := raw.(string) if i := strings.IndexByte(s, '?'); i >= 0 { return s[:i] } return s } func TestLearningDetailMatchesActiveStudents(t *testing.T) { e := setup(t) admin := login(t, e, bootstrapAdminEmail) code, body := e.do(t, "GET", "/api/admin/stats", admin, nil) if code != 200 { t.Fatalf("stats: %d %v", code, body) } if body["stats"].(map[string]any)["users"].(float64) != 0 { t.Fatalf("overview should ignore staff, got %v", body["stats"]) } _, uid := register(t, e, "Active Student", "active-student@test.dev") code, body = e.do(t, "GET", "/api/admin/stats", admin, nil) if code != 200 { t.Fatalf("stats after student: %d %v", code, body) } if body["stats"].(map[string]any)["users"].(float64) != 1 { t.Fatalf("expected 1 active student, got %v", body["stats"]) } code, body = e.do(t, "GET", "/api/admin/learning/overview", admin, nil) if code != 200 { t.Fatalf("overview: %d %v", code, body) } if body["overview"].(map[string]any)["studentsActive"].(float64) != 1 { t.Fatalf("learning active mismatch: %v", body["overview"]) } code, body = e.do(t, "GET", "/api/admin/users/"+itoa(uid)+"/learning", admin, nil) if code != 200 { t.Fatalf("learning detail: %d %v", code, body) } user := body["learning"].(map[string]any)["user"].(map[string]any) if user["name"] != "Active Student" { t.Fatalf("detail user: %v", user) } code, body = e.do(t, "GET", "/api/admin/users/999999/learning", admin, nil) if code != 404 { t.Fatalf("missing user: %d %v", code, body) } code, body = e.do(t, "GET", "/api/admin/learning/students?page=1&pageSize=10", admin, nil) if code != 200 { t.Fatalf("students page: %d %v", code, body) } if body["total"].(float64) != 1 || len(body["items"].([]any)) != 1 { t.Fatalf("expected one page row, got %v", body) } } func TestInviteStatusFilter(t *testing.T) { e := setup(t) admin := login(t, e, bootstrapAdminEmail) code, body := e.do(t, "POST", "/api/admin/invites", admin, map[string]any{"maxUses": 2, "label": "مهر"}) if code != 201 { t.Fatalf("create invite: %d %v", code, body) } code, body = e.do(t, "GET", "/api/admin/invites?status=active", admin, nil) if code != 200 { t.Fatalf("active filter: %d %v", code, body) } if body["total"].(float64) < 1 { t.Fatalf("expected active invites, got %v", body) } code, body = e.do(t, "GET", "/api/admin/invites?status=revoked", admin, nil) if code != 200 { t.Fatalf("revoked filter: %d %v", code, body) } if body["total"].(float64) != 0 { t.Fatalf("expected no revoked invites, got %v", body) } code, body = e.do(t, "GET", "/api/admin/invites?status=nope", admin, nil) if code != 400 { t.Fatalf("bad status: %d %v", code, body) } } func TestPublicProfileOmitsPrivateFields(t *testing.T) { e := setup(t) student, studentID := register(t, e, "Public Name", "public-name@test.dev") admin := login(t, e, bootstrapAdminEmail) code, me := e.do(t, "GET", "/api/auth/me", admin, nil) if code != 200 { t.Fatalf("me: %d %v", code, me) } adminID := int64(me["user"].(map[string]any)["id"].(float64)) code, body := e.do(t, "GET", "/api/users/"+itoa(adminID), student, nil) if code != 200 { t.Fatalf("profile: %d %v", code, body) } user := body["user"].(map[string]any) if user["name"] == nil || user["name"] == "" { t.Fatalf("missing name: %v", user) } if _, ok := user["email"]; ok { t.Fatalf("email must not be public: %v", user) } if _, ok := user["phone"]; ok { t.Fatalf("phone must not be public: %v", user) } code, adminView := e.do(t, "GET", "/api/admin/users/"+itoa(studentID), admin, nil) if code != 200 { t.Fatalf("admin user: %d %v", code, adminView) } managed := adminView["user"].(map[string]any) if managed["email"] != "public-name@test.dev" { t.Fatalf("admin should see email: %v", managed) } if _, ok := managed["passwordHash"]; ok { t.Fatalf("password hash must stay hidden: %v", managed) } code, hidden := e.do(t, "GET", "/api/admin/users/"+itoa(adminID), student, nil) if code != 403 { t.Fatalf("student must not open admin user: %d %v", code, hidden) } code, body = e.do(t, "GET", "/api/users/"+itoa(studentID), admin, nil) if code != 200 { t.Fatalf("student profile: %d %v", code, body) } if body["user"].(map[string]any)["username"] != "public-name" { t.Fatalf("username: %v", body["user"]) } }