veza/veza-backend-api/internal/handlers/upgrade_creator_handler_test.go
senke 9f4c2183a2 feat(backend,web): self-service creator role upgrade via /settings
First item of the v1.0.6 backlog surfaced by the v1.0.5 smoke test: a
brand-new account could register, verify email, and log in — but
attempting to upload hit a 403 because `role='user'` doesn't pass the
`RequireContentCreatorRole` middleware. The only way to get past that
gate was an admin DB update.

This commit wires the self-service path decided in the v1.0.6
specification:

  * One-way flip from `role='user'` to `role='creator'`, gated strictly
    on `is_verified=true` (the verification-email flow we restored in
    Fix 2 of the hardening sprint).
  * No KYC, no cooldown, no admin validation. The conscious click
    already requires ownership of the email address.
  * Downgrade is out of scope — a creator who wants back to `user`
    opens a support ticket. Avoids the "my uploads orphaned" edge case.

Backend
  * Migration `977_users_promoted_to_creator_at.sql`: nullable
    `TIMESTAMPTZ` column, partial index for non-null values. NULL
    preserves the semantic for users who never self-promoted
    (out-of-band admin assignments stay distinguishable from organic
    creators for audit/analytics).
  * `models.User`: new `PromotedToCreatorAt *time.Time` field.
  * `handlers.UpgradeToCreator(db, auditService, logger)`:
      - 401 if no `user_id` in context (belt-and-braces — middleware
        should catch this first)
      - 404 if the user row is missing
      - 403 `EMAIL_NOT_VERIFIED` when `is_verified=false`
      - 200 idempotent with `already_elevated=true` when the caller is
        already creator / premium / moderator / admin / artist /
        producer / label (same set accepted by
        `RequireContentCreatorRole`)
      - 200 with the new role + `promoted_to_creator_at` on the happy
        path. The UPDATE is scoped `WHERE role='user'` so a concurrent
        admin assignment can't be silently overwritten; the zero-rows
        case reloads and returns `already_elevated=true`.
      - audit logs a `user.upgrade_creator` action with IP, UA, and
        the role transition metadata. Non-fatal on failure — the
        upgrade itself already committed.
  * Route: `POST /api/v1/users/me/upgrade-creator` under the existing
    protected users group (RequireAuth + CSRF).

Frontend
  * `AccountSettingsCreatorCard`: new card in the Account tab of
    `/settings`. Completely hidden for users already on a creator-tier
    role (no "you're already a creator" clutter). Unverified users see
    a disabled-but-explanatory state with a "Resend verification"
    CTA to `/verify-email/resend`. Verified users see the "Become an
    artist" button, which POSTs to `/users/me/upgrade-creator` and
    refetches the user on success.
  * `upgradeToCreator()` service in `features/settings/services/`.
  * Copy is deliberately explicit that the change is one-way.

Tests
  * 6 Go unit tests covering: happy path (role + timestamp), unverified
    refused, already-creator idempotent (timestamp preserved),
    admin-assigned idempotent (no timestamp overwrite), user-not-found,
    no-auth-context.
  * 7 Vitest tests covering: verified button visible, unverified state
    shown, card hidden for creator, card hidden for admin, success +
    refetch, idempotent message, server error via toast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:35:07 +02:00

154 lines
5.1 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"veza-backend-api/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupUpgradeCreatorTest(t *testing.T) (*gin.Engine, *gorm.DB, uuid.UUID) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
db.Exec("PRAGMA foreign_keys = ON")
require.NoError(t, db.AutoMigrate(&models.User{}))
userID := uuid.New()
// Default seed: verified user with role=user. Individual tests can override.
require.NoError(t, db.Create(&models.User{
ID: userID,
Username: "upgrader",
Email: "upgrade@example.com",
Role: "user",
IsVerified: true,
}).Error)
logger := zaptest.NewLogger(t)
router := gin.New()
router.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
return router, db, userID
}
func postUpgrade(t *testing.T, router *gin.Engine) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/users/me/upgrade-creator", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func TestUpgradeToCreator_VerifiedUserIsPromoted(t *testing.T) {
router, db, userID := setupUpgradeCreatorTest(t)
w := postUpgrade(t, router)
assert.Equal(t, http.StatusOK, w.Code)
var resp UpgradeCreatorResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "creator", resp.Role)
assert.False(t, resp.AlreadyElevated)
require.NotNil(t, resp.PromotedToCreatorAt)
assert.WithinDuration(t, time.Now(), *resp.PromotedToCreatorAt, 5*time.Second)
var after models.User
require.NoError(t, db.First(&after, "id = ?", userID).Error)
assert.Equal(t, "creator", after.Role)
require.NotNil(t, after.PromotedToCreatorAt)
}
func TestUpgradeToCreator_UnverifiedIsRefused(t *testing.T) {
router, db, userID := setupUpgradeCreatorTest(t)
// Flip is_verified=false; default seed had it true.
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Update("is_verified", false).Error)
w := postUpgrade(t, router)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "EMAIL_NOT_VERIFIED")
var after models.User
require.NoError(t, db.First(&after, "id = ?", userID).Error)
assert.Equal(t, "user", after.Role, "role must not flip when email is not verified")
assert.Nil(t, after.PromotedToCreatorAt)
}
func TestUpgradeToCreator_AlreadyCreatorIsIdempotent(t *testing.T) {
router, db, userID := setupUpgradeCreatorTest(t)
earlier := time.Now().Add(-48 * time.Hour).UTC()
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"role": "creator",
"promoted_to_creator_at": earlier,
}).Error)
w := postUpgrade(t, router)
assert.Equal(t, http.StatusOK, w.Code)
var resp UpgradeCreatorResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "creator", resp.Role)
assert.True(t, resp.AlreadyElevated)
require.NotNil(t, resp.PromotedToCreatorAt)
assert.WithinDuration(t, earlier, *resp.PromotedToCreatorAt, time.Second,
"idempotent path must preserve the original promotion timestamp")
}
func TestUpgradeToCreator_AdminIsIdempotentWithoutTimestamp(t *testing.T) {
router, db, userID := setupUpgradeCreatorTest(t)
// Admin was assigned out-of-band — no promoted_to_creator_at.
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Update("role", "admin").Error)
w := postUpgrade(t, router)
assert.Equal(t, http.StatusOK, w.Code)
var resp UpgradeCreatorResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "admin", resp.Role, "admin must keep their role, not be downgraded to creator")
assert.True(t, resp.AlreadyElevated)
assert.Nil(t, resp.PromotedToCreatorAt, "admin assigned without the self-service flow has no timestamp")
}
func TestUpgradeToCreator_UserNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.User{}))
logger := zaptest.NewLogger(t)
router := gin.New()
// Inject a user_id that doesn't exist in the DB
router.Use(func(c *gin.Context) { c.Set("user_id", uuid.New()); c.Next() })
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
w := postUpgrade(t, router)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "USER_NOT_FOUND")
}
func TestUpgradeToCreator_NoAuthContext(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.User{}))
logger := zaptest.NewLogger(t)
router := gin.New()
// No middleware that sets user_id
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
w := postUpgrade(t, router)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "UNAUTHORIZED")
}