Migrate the hardcoded wechat field to the new extensible user attributes system and improve the users management UI. Migration: - Add migration 019 to move wechat data to user_attribute_values - Remove wechat field from User entity, DTOs, and API contracts - Clean up wechat-related code from backend and frontend UsersView enhancements: - Add text labels to action buttons (Filter Settings, Column Settings, Attributes Config) for better UX - Change status column to show colored dot + Chinese text instead of English text - Add dynamic attribute columns support with batch loading - Add column visibility settings with localStorage persistence - Add filter settings modal for search and filter preferences - Update i18n translations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
103 lines
4.1 KiB
Go
103 lines
4.1 KiB
Go
//go:build integration
|
|
|
|
package repository
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
|
|
tx := testTx(t)
|
|
|
|
// Re-apply migrations to verify idempotency (no errors, no duplicate rows).
|
|
require.NoError(t, ApplyMigrations(context.Background(), integrationDB))
|
|
|
|
// schema_migrations should have at least the current migration set.
|
|
var applied int
|
|
require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_migrations").Scan(&applied))
|
|
require.GreaterOrEqual(t, applied, 7, "expected schema_migrations to contain applied migrations")
|
|
|
|
// users: columns required by repository queries
|
|
requireColumn(t, tx, "users", "username", "character varying", 100, false)
|
|
requireColumn(t, tx, "users", "notes", "text", 0, false)
|
|
|
|
// accounts: schedulable and rate-limit fields
|
|
requireColumn(t, tx, "accounts", "schedulable", "boolean", 0, false)
|
|
requireColumn(t, tx, "accounts", "rate_limited_at", "timestamp with time zone", 0, true)
|
|
requireColumn(t, tx, "accounts", "rate_limit_reset_at", "timestamp with time zone", 0, true)
|
|
requireColumn(t, tx, "accounts", "overload_until", "timestamp with time zone", 0, true)
|
|
requireColumn(t, tx, "accounts", "session_window_status", "character varying", 20, true)
|
|
|
|
// api_keys: key length should be 128
|
|
requireColumn(t, tx, "api_keys", "key", "character varying", 128, false)
|
|
|
|
// redeem_codes: subscription fields
|
|
requireColumn(t, tx, "redeem_codes", "group_id", "bigint", 0, true)
|
|
requireColumn(t, tx, "redeem_codes", "validity_days", "integer", 0, false)
|
|
|
|
// usage_logs: billing_type used by filters/stats
|
|
requireColumn(t, tx, "usage_logs", "billing_type", "smallint", 0, false)
|
|
|
|
// settings table should exist
|
|
var settingsRegclass sql.NullString
|
|
require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.settings')").Scan(&settingsRegclass))
|
|
require.True(t, settingsRegclass.Valid, "expected settings table to exist")
|
|
|
|
// user_allowed_groups table should exist
|
|
var uagRegclass sql.NullString
|
|
require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.user_allowed_groups')").Scan(&uagRegclass))
|
|
require.True(t, uagRegclass.Valid, "expected user_allowed_groups table to exist")
|
|
|
|
// user_subscriptions: deleted_at for soft delete support (migration 012)
|
|
requireColumn(t, tx, "user_subscriptions", "deleted_at", "timestamp with time zone", 0, true)
|
|
|
|
// orphan_allowed_groups_audit table should exist (migration 013)
|
|
var orphanAuditRegclass sql.NullString
|
|
require.NoError(t, tx.QueryRowContext(context.Background(), "SELECT to_regclass('public.orphan_allowed_groups_audit')").Scan(&orphanAuditRegclass))
|
|
require.True(t, orphanAuditRegclass.Valid, "expected orphan_allowed_groups_audit table to exist")
|
|
|
|
// account_groups: created_at should be timestamptz
|
|
requireColumn(t, tx, "account_groups", "created_at", "timestamp with time zone", 0, false)
|
|
|
|
// user_allowed_groups: created_at should be timestamptz
|
|
requireColumn(t, tx, "user_allowed_groups", "created_at", "timestamp with time zone", 0, false)
|
|
}
|
|
|
|
func requireColumn(t *testing.T, tx *sql.Tx, table, column, dataType string, maxLen int, nullable bool) {
|
|
t.Helper()
|
|
|
|
var row struct {
|
|
DataType string
|
|
MaxLen sql.NullInt64
|
|
Nullable string
|
|
}
|
|
|
|
err := tx.QueryRowContext(context.Background(), `
|
|
SELECT
|
|
data_type,
|
|
character_maximum_length,
|
|
is_nullable
|
|
FROM information_schema.columns
|
|
WHERE table_schema = 'public'
|
|
AND table_name = $1
|
|
AND column_name = $2
|
|
`, table, column).Scan(&row.DataType, &row.MaxLen, &row.Nullable)
|
|
require.NoError(t, err, "query information_schema.columns for %s.%s", table, column)
|
|
require.Equal(t, dataType, row.DataType, "data_type mismatch for %s.%s", table, column)
|
|
|
|
if maxLen > 0 {
|
|
require.True(t, row.MaxLen.Valid, "expected maxLen for %s.%s", table, column)
|
|
require.Equal(t, int64(maxLen), row.MaxLen.Int64, "maxLen mismatch for %s.%s", table, column)
|
|
}
|
|
|
|
if nullable {
|
|
require.Equal(t, "YES", row.Nullable, "nullable mismatch for %s.%s", table, column)
|
|
} else {
|
|
require.Equal(t, "NO", row.Nullable, "nullable mismatch for %s.%s", table, column)
|
|
}
|
|
}
|