Files
ahp 22d92c133d feat: release v0.2.0 profiles, chat, freeze, and bots
Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
2026-10-10 16:42:35 +03:30

618 lines
21 KiB
Go

package api_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
func TestAuthRoundTrip(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Tester", "tester@test.dev")
if uid <= 0 {
t.Fatal("expected positive id")
}
code, body := e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
u := body["user"].(map[string]any)
if u["email"] != "tester@test.dev" {
t.Fatalf("wrong user %v", u["email"])
}
if u["role"] != "student" {
t.Fatalf("role must default to student, got %v", u["role"])
}
// public self-registration is disabled; duplicate email rejected via admin path
code, _ = e.do(t, "POST", "/api/auth/register", "", map[string]any{
"name": "Tester", "email": "tester@test.dev", "password": "password123",
})
if code == http.StatusCreated {
t.Fatal("public registration must be disabled")
}
admin := login(t, e, bootstrapAdminEmail)
code, _ = e.do(t, "POST", "/api/admin/users", admin, map[string]any{
"name": "Tester", "email": "tester@test.dev", "username": "tester", "password": "password123", "role": "student",
"securityQuestion": "رنگ مورد علاقه؟", "securityAnswer": "آبی",
})
if code != http.StatusConflict {
t.Fatalf("expected 409, got %d", code)
}
}
func TestTreeGatekeeping(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Tree", "tree@test.dev")
code, body := e.do(t, "GET", "/api/tree", token, nil)
if code != http.StatusOK {
t.Fatalf("tree: %d", code)
}
chapters := body["chapters"].([]any)
if len(chapters) != 3 {
t.Fatalf("expected 3 chapters, got %d", len(chapters))
}
firstChapter := chapters[0].(map[string]any)
lessons := firstChapter["lessons"].([]any)
first := lessons[0].(map[string]any)
if first["locked"] != false {
t.Fatalf("root lesson must be unlocked")
}
second := lessons[1].(map[string]any)
if second["locked"] != true {
t.Fatalf("prereq lesson must start locked")
}
}
func TestPrerequisiteLessonRejectsDirectAccess(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Direct", "direct@test.dev")
for _, endpoint := range []struct {
method string
path string
body map[string]any
}{
{method: "GET", path: "/api/lessons/2"},
{method: "GET", path: "/api/lessons/2/resume"},
{method: "POST", path: "/api/lessons/2/heartbeat", body: map[string]any{"position": 1, "delta": 1, "seq": 1}},
{method: "POST", path: "/api/lessons/2/complete"},
{method: "GET", path: "/api/lessons/2/quiz"},
{method: "POST", path: "/api/lessons/2/quiz/submit", body: map[string]any{"answers": []int{0}}},
{method: "GET", path: "/api/lessons/2/quiz/result"},
} {
code, _ := e.do(t, endpoint.method, endpoint.path, token, endpoint.body)
if code != http.StatusForbidden {
t.Fatalf("%s %s: expected 403, got %d", endpoint.method, endpoint.path, code)
}
}
}
func TestHeartbeatReportsUnlockTransition(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Heartbeat", "heartbeat@test.dev")
for i := 1; i <= 5; i++ {
rewindWatchSession(t, e, token, 1)
code, body := e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{
"position": float64(i * 7),
"delta": 7,
"seq": i,
})
if code != http.StatusOK || body["quizUnlocked"] != false || body["quizUnlockedNow"] != false {
t.Fatalf("heartbeat %d should remain locked: %d %v", i, code, body)
}
}
rewindWatchSession(t, e, token, 1)
code, body := e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{
"position": 42, "delta": 4, "seq": 6,
})
if code != http.StatusOK || body["quizUnlocked"] != true || body["quizUnlockedNow"] != true {
t.Fatalf("unlocking heartbeat should report transition: %d %v", code, body)
}
rewindWatchSession(t, e, token, 1)
code, body = e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{
"position": 43, "delta": 1, "seq": 7,
})
if code != http.StatusOK || body["quizUnlocked"] != true || body["quizUnlockedNow"] != false {
t.Fatalf("subsequent heartbeat should report persistent unlock only: %d %v", code, body)
}
}
func TestQuizRequiresWatch(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Cheat", "cheat@test.dev")
code, _ := e.do(t, "GET", "/api/lessons/1/quiz", token, nil)
if code != http.StatusForbidden {
t.Fatalf("quiz must be locked before watch, got %d", code)
}
}
func TestWatchUnlocksQuizAndPass(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Win", "win@test.dev")
watchToUnlock(t, e, token, 1)
code, body := e.do(t, "GET", "/api/lessons/1/quiz", token, nil)
if code != http.StatusOK {
t.Fatalf("quiz not unlocked after watch: %d", code)
}
questions := body["questions"].([]any)
if len(questions) != 3 {
t.Fatalf("expected 3 questions")
}
// answer keys must never leak to clients
for _, q := range questions {
qm := q.(map[string]any)
if _, ok := qm["answerIndex"]; ok {
t.Fatalf("answer key leaked to client!")
}
}
// graded server-side; correct answers for seeded lesson 1 are [1,1,2]
code, result := e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{1, 1, 2}})
if code != http.StatusOK {
t.Fatalf("submit: %d %v", code, result)
}
if result["passed"] != true {
t.Fatalf("expected pass, got %v", result)
}
if result["xpEarned"].(float64) <= 0 {
t.Fatalf("expected xp")
}
// lessons 2 and 3 must now be unlocked
_, body = e.do(t, "GET", "/api/tree", token, nil)
chapters := body["chapters"].([]any)
found := 0
for _, ch := range chapters {
for _, l := range ch.(map[string]any)["lessons"].([]any) {
lm := l.(map[string]any)
switch lm["id"].(float64) {
case 2, 3:
if lm["locked"] != false {
t.Fatalf("lesson %v should be unlocked after passing prereq", lm["id"])
}
found++
}
}
}
if found != 2 {
t.Fatalf("expected 2 unlocked lessons, got %d", found)
}
}
func TestQuizFailLocksAccountAndMentorUnlocks(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Struggle", "struggle@test.dev")
watchToUnlock(t, e, token, 1)
adminEarly := login(t, e, bootstrapAdminEmail)
code, _ := e.do(t, "PUT", "/api/admin/users/"+itoa(uid), adminEarly, map[string]any{"hearts": 3})
if code != http.StatusOK {
t.Fatalf("set hearts: %d", code)
}
// all-wrong answers spend the starting hearts. Zero does not lock by default.
var result map[string]any
code, result = e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}})
if code != http.StatusOK {
t.Fatalf("submit: %d", code)
}
if result["heartsLeft"].(float64) != 0 {
t.Fatalf("expected 0 hearts")
}
if result["locked"] == true {
t.Fatalf("empty hearts must not lock by default")
}
code, _ = e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{"position": 45, "delta": 1, "seq": 9})
if code == http.StatusLocked {
t.Fatalf("heartbeat should stay open while waiting for a heart")
}
code, _ = e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}})
if code != http.StatusForbidden {
t.Fatalf("expected quiz blocked at 0 hearts, got %d", code)
}
// A manual lock still accepts a review request. Empty hearts alone do not.
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/lock", adminEarly, nil)
if code != http.StatusOK {
t.Fatalf("manual lock: %d", code)
}
code, body := e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "لطفاً بررسی کنید"})
if code != http.StatusCreated && code != http.StatusOK {
t.Fatalf("unlock request: %d %v", code, body)
}
code, _ = e.do(t, "POST", "/api/me/request-reset", token, nil)
if code != http.StatusForbidden {
t.Fatalf("self reset should be forbidden, got %d", code)
}
// mentor unlocks (account CRUD is admin-only; unlock remains staff)
admin := login(t, e, bootstrapAdminEmail)
code, _ = e.do(t, "POST", "/api/admin/users", admin, map[string]any{
"name": "Mentor Maya", "email": "mentor@test.dev", "username": "mentor", "password": "password123", "role": "mentor",
"securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex",
})
if code != http.StatusCreated {
t.Fatalf("create mentor: %d", code)
}
mentor := login(t, e, "mentor@test.dev")
code, _ = e.do(t, "GET", "/api/admin/users?q=struggle", mentor, nil)
if code != http.StatusForbidden {
t.Fatalf("mentor listing users should be 403, got %d", code)
}
code, body = e.do(t, "GET", "/api/admin/learning/students?q=struggle", mentor, nil)
if code != http.StatusOK {
t.Fatalf("mentor learning students: %d %v", code, body)
}
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/unlock", mentor, nil)
if code != http.StatusOK {
t.Fatalf("unlock: %d", code)
}
code, body = e.do(t, "GET", "/api/auth/me", token, nil)
u := body["user"].(map[string]any)
if code != http.StatusOK || u["isLocked"] != false {
t.Fatalf("user should be unlocked: %v", u["isLocked"])
}
if u["hearts"].(float64) != 5 {
t.Fatalf("hearts should be restored to 5, got %v", u["hearts"])
}
}
func TestAdminHeartOverflowLocksAndCapsAccount(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Overflow", "overflow@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "GET", "/api/admin/users?q=overflow", admin, nil)
if code != http.StatusOK {
t.Fatalf("admin users: %d", code)
}
uid := int64(body["items"].([]any)[0].(map[string]any)["id"].(float64))
code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 9})
if code != http.StatusOK {
t.Fatalf("update user: %d", code)
}
user := body["user"].(map[string]any)
if user["hearts"].(float64) != 5 || user["isLocked"] != true {
t.Fatalf("overflow must cap hearts and lock account: %v", user)
}
code, body = e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
user = body["user"].(map[string]any)
if user["hearts"].(float64) != 5 || user["isLocked"] != true {
t.Fatalf("persisted overflow guard failed: %v", user)
}
}
func TestCannotChangeOwnRoleOrLockSelf(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
adminID := int64(body["user"].(map[string]any)["id"].(float64))
code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(adminID), admin, map[string]any{"role": "student"})
if code != http.StatusBadRequest {
t.Fatalf("self role change via update should be 400, got %d %v", code, body)
}
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(adminID)+"/role", admin, map[string]any{"role": "mentor"})
if code != http.StatusBadRequest {
t.Fatalf("self role change via set-role should be 400, got %d %v", code, body)
}
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(adminID)+"/lock", admin, nil)
if code != http.StatusBadRequest {
t.Fatalf("self lock should be 400, got %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/auth/me", admin, nil)
if code != http.StatusOK {
t.Fatalf("me after: %d", code)
}
if body["user"].(map[string]any)["role"] != "admin" {
t.Fatalf("admin role must remain admin, got %v", body["user"])
}
}
func TestEventsRsvpICSAndChat(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Social", "social@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
"name": "Mentor Maya", "email": "mentor@test.dev", "username": "mentor", "password": "password123", "role": "mentor",
"securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex",
})
if code != http.StatusCreated {
t.Fatalf("create mentor: %d %v", code, body)
}
mentorID := int64(body["user"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/events", token, nil)
if code != http.StatusOK {
t.Fatalf("events: %d", code)
}
events := body["events"].([]any)
if len(events) != 3 {
t.Fatalf("expected 3 seeded events, got %d", len(events))
}
evID := int64(events[0].(map[string]any)["id"].(float64))
code, _ = e.do(t, "POST", "/api/events/"+itoa(evID)+"/rsvp", token, nil)
if code != http.StatusOK {
t.Fatalf("rsvp: %d", code)
}
// authenticated ICS download
req, _ := http.NewRequest("GET", e.server.URL+"/api/events/"+itoa(evID)+"/ics", nil)
req.Header.Set("Authorization", "Bearer "+token)
iresp, _ := http.DefaultClient.Do(req)
if iresp.StatusCode != http.StatusOK {
t.Fatalf("ics: %d", iresp.StatusCode)
}
// chat with mentor (id 2)
code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", token, map[string]any{"body": "hello mentor"})
if code != http.StatusCreated {
t.Fatalf("chat: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(mentorID)+"/messages", token, nil)
if code != http.StatusOK {
t.Fatalf("messages: %d", code)
}
msgs := body["messages"].([]any)
if len(msgs) != 1 || msgs[0].(map[string]any)["body"] != "hello mentor" {
t.Fatalf("chat roundtrip failed: %v", msgs)
}
}
func TestChatRecipientPermissions(t *testing.T) {
e := setup(t)
studentToken, studentID := register(t, e, "One", "one@test.dev")
_, otherStudentID := register(t, e, "Two", "two@test.dev")
adminToken := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/users", adminToken, map[string]any{
"name": "Mentor Maya", "email": "mentor@test.dev", "username": "mentor", "password": "password123", "role": "mentor",
"securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex",
})
if code != http.StatusCreated {
t.Fatalf("create mentor: %d %v", code, body)
}
mentorID := int64(body["user"].(map[string]any)["id"].(float64))
code, _ = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", studentToken, map[string]any{"body": "saved"})
if code != http.StatusCreated {
t.Fatalf("saved messages: %d", code)
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(otherStudentID)+"/messages", studentToken, map[string]any{"body": "not allowed"})
if code != http.StatusForbidden {
t.Fatalf("student→student must be forbidden, got %d", code)
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", studentToken, map[string]any{"body": "ok"})
if code != http.StatusCreated {
t.Fatalf("student→mentor should work: %d", code)
}
}
func TestChatAroundWindow(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Around", "around@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
"name": "Mentor Around", "email": "mentor-around@test.dev", "username": "mentoraround", "password": "password123", "role": "mentor",
"securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex",
})
if code != http.StatusCreated {
t.Fatalf("create mentor: %d %v", code, body)
}
mentorID := int64(body["user"].(map[string]any)["id"].(float64))
var mid int64
for i := 0; i < 5; i++ {
code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", token, map[string]any{
"body": "msg-" + itoa(int64(i)),
})
if code != http.StatusCreated {
t.Fatalf("send %d: %d %v", i, code, body)
}
id := int64(body["message"].(map[string]any)["id"].(float64))
if i == 2 {
mid = id
}
}
if mid == 0 {
t.Fatal("missing midpoint id")
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(mentorID)+"/messages?around="+itoa(mid), token, nil)
if code != http.StatusOK {
t.Fatalf("around: %d %v", code, body)
}
msgs := body["messages"].([]any)
found := false
for _, raw := range msgs {
if int64(raw.(map[string]any)["id"].(float64)) == mid {
found = true
break
}
}
if !found {
t.Fatalf("around window missing target %d: %v", mid, msgs)
}
if int64(body["around"].(float64)) != mid {
t.Fatalf("around echo mismatch: %v", body["around"])
}
code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages/"+itoa(mid)+"/pin", token, nil)
if code != http.StatusOK {
t.Fatalf("pin: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(mentorID)+"/messages", token, nil)
if code != http.StatusOK {
t.Fatalf("list: %d", code)
}
pinned := body["pinned"].([]any)
if len(pinned) != 1 {
t.Fatalf("expected 1 pin, got %d", len(pinned))
}
}
func TestUnlockRequestQueuesStaffReview(t *testing.T) {
e := setup(t)
token, studentID := register(t, e, "LockedKid", "lockedkid@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, _ := e.do(t, "POST", "/api/admin/users/"+itoa(studentID)+"/lock", admin, nil)
if code != http.StatusOK {
t.Fatalf("admin lock: %d", code)
}
code, body := e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "کمک لازم دارم"})
if code != http.StatusCreated {
t.Fatalf("unlock request: %d %v", code, body)
}
// second request within window should be idempotent OK
code, body = e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "دوباره"})
if code != http.StatusOK {
t.Fatalf("repeat unlock request: %d %v", code, body)
}
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(studentID)+"/unlock", admin, nil)
if code != http.StatusOK {
t.Fatalf("admin unlock: %d", code)
}
code, body = e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK || body["user"].(map[string]any)["isLocked"] != false {
t.Fatalf("should be unlocked: %d %v", code, body)
}
}
func TestNotificationsFanOutAndPrefs(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Notified", "notified@test.dev")
// prefs exist & mutable
code, body := e.do(t, "GET", "/api/notifications/preferences", token, nil)
if code != http.StatusOK {
t.Fatalf("prefs: %d", code)
}
code, _ = e.do(t, "PUT", "/api/notifications/preferences", token, map[string]any{
"progress": false, "gamification": true, "mentor": true, "event": false,
})
if code != http.StatusOK {
t.Fatalf("put prefs: %d", code)
}
// produce notifications (gamification on, progress off)
watchToUnlock(t, e, token, 1)
e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{1, 1, 2}})
code, body = e.do(t, "GET", "/api/notifications", token, nil)
if code != http.StatusOK {
t.Fatalf("notifs: %d", code)
}
unread := int(body["unread"].(float64))
if unread == 0 {
t.Fatal("expected notifications to be created")
}
found := false
for _, n := range body["notifications"].([]any) {
if n.(map[string]any)["type"] == "quiz_passed" {
found = true
}
}
if !found {
t.Fatal("expected gamification quiz_passed notification")
}
code, body = e.do(t, "GET", "/api/notifications/unread-count", token, nil)
if code != http.StatusOK || int(body["unread"].(float64)) != unread {
t.Fatalf("unread count mismatch")
}
code, _ = e.do(t, "POST", "/api/notifications/read-all", token, nil)
if code != http.StatusOK {
t.Fatalf("read-all: %d", code)
}
code, body = e.do(t, "GET", "/api/notifications/unread-count", token, nil)
if code != http.StatusOK || int(body["unread"].(float64)) != 0 {
t.Fatalf("expected 0 unread after read-all, got %v", body)
}
}
func TestLeaderboardAfterXP(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Orc", "orc@test.dev")
watchToUnlock(t, e, token, 1)
e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{1, 1, 2}})
code, body := e.do(t, "GET", "/api/leaderboard/weekly", token, nil)
if code != http.StatusOK {
t.Fatalf("leaderboard: %d", code)
}
entries := body["entries"].([]any)
if len(entries) == 0 {
t.Fatal("expected leaderboard entries")
}
if entries[0].(map[string]any)["xp"].(float64) <= 0 {
t.Fatalf("expected positive xp")
}
fmt.Println("leaderboard ok")
}
func TestAdminSettingsAndSponsors(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{
"settings": map[string]any{
"social_links": `[{"name":"تلگرام","url":"https://t.me/pargar"}]`,
"sponsors": `[{"name":"حامی یک","url":"https://example.com","blurb":"حمایت"}]`,
},
})
if code != http.StatusOK {
t.Fatalf("put settings: %d %v", code, body)
}
settings := body["settings"].(map[string]any)
if !strings.Contains(settings["social_links"].(string), "تلگرام") {
t.Fatalf("social links not saved: %v", settings["social_links"])
}
code, body = e.do(t, "GET", "/api/sponsors", "", nil)
if code != http.StatusOK {
t.Fatalf("sponsors: %d %v", code, body)
}
sponsors := body["sponsors"].([]any)
if len(sponsors) != 1 || sponsors[0].(map[string]any)["name"] != "حامی یک" {
t.Fatalf("unexpected sponsors: %v", sponsors)
}
code, body = e.do(t, "GET", "/api/community", "", nil)
if code != http.StatusOK {
t.Fatalf("community: %d %v", code, body)
}
if _, ok := body["channel"]; ok {
t.Fatalf("community must not expose telegram channel: %v", body["channel"])
}
socials, _ := body["socials"].([]any)
if len(socials) != 1 || socials[0].(map[string]any)["name"] != "تلگرام" {
t.Fatalf("community socials: %v", body["socials"])
}
}