test(project): achieve ≥70% package coverage across all internal packages
- store/sqlite: 75.4% (repos + db coverage) - host/sub2api: 80.8% (httptest mock server, pure function tests) - app: 74.2% (handler error paths, NewActionSet closures) - pack: 72.4% - provision: 75.2% - access: 77.3% - config: 94.7% (lookup mock tests) All tests pass: build, vet, race, coverage gates.
This commit is contained in:
@@ -66,9 +66,9 @@ func TestLoadAdminTokenFromEnvReturnsErrorWhenMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapBuildsServerWithStartupConfigOnly(t *testing.T) {
|
||||
func TestBootstrapBuildsServerWithStartupConfigAndAdminToken(t *testing.T) {
|
||||
t.Setenv("SUB2API_CRM_LISTEN_ADDR", ":8181")
|
||||
t.Setenv("SUB2API_CRM_ADMIN_TOKEN", "")
|
||||
t.Setenv("SUB2API_CRM_ADMIN_TOKEN", "admin-token")
|
||||
|
||||
server, err := app.Bootstrap(context.Background())
|
||||
if err != nil {
|
||||
|
||||
64
tests/integration/distribution_smoke_test.go
Normal file
64
tests/integration/distribution_smoke_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDistributionArtifactsExistAndReferenceRequiredEnv(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
for _, path := range []string{
|
||||
filepath.Join(root, "Dockerfile"),
|
||||
filepath.Join(root, ".env.example"),
|
||||
filepath.Join(root, "docker-compose.yml"),
|
||||
filepath.Join(root, "docs", "DEPLOYMENT.md"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("required distribution artifact %q missing: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
dockerfile := mustReadText(t, filepath.Join(root, "Dockerfile"))
|
||||
if !strings.Contains(dockerfile, "SUB2API_CRM_ADMIN_TOKEN") {
|
||||
t.Fatalf("Dockerfile must document runtime dependency on SUB2API_CRM_ADMIN_TOKEN; content=%s", dockerfile)
|
||||
}
|
||||
|
||||
envExample := mustReadText(t, filepath.Join(root, ".env.example"))
|
||||
for _, key := range []string{"SUB2API_CRM_LISTEN_ADDR", "SUB2API_CRM_SQLITE_DSN", "SUB2API_CRM_ADMIN_TOKEN"} {
|
||||
if !strings.Contains(envExample, key+"=") {
|
||||
t.Fatalf(".env.example missing %s; content=%s", key, envExample)
|
||||
}
|
||||
}
|
||||
|
||||
compose := mustReadText(t, filepath.Join(root, "docker-compose.yml"))
|
||||
if !strings.Contains(compose, "/healthz") {
|
||||
t.Fatalf("docker-compose.yml missing healthz probe; content=%s", compose)
|
||||
}
|
||||
|
||||
deployment := mustReadText(t, filepath.Join(root, "docs", "DEPLOYMENT.md"))
|
||||
for _, needle := range []string{"docker compose up --build -d", "SUB2API_CRM_ADMIN_TOKEN", "/healthz"} {
|
||||
if !strings.Contains(deployment, needle) {
|
||||
t.Fatalf("DEPLOYMENT.md missing %q; content=%s", needle, deployment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repo root: %v", err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func mustReadText(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
@@ -193,6 +193,89 @@ func TestSub2APIHostAdapterGetAccountModelsParsesEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub2APIHostAdapterChecksGatewayAccess(t *testing.T) {
|
||||
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
||||
requireAPIKey: true,
|
||||
version: "0.1.126",
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client, err := sub2api.NewClient(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{
|
||||
APIKey: "user-api-key",
|
||||
ExpectedModel: "deepseek-chat",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CheckGatewayAccess() error = %v", err)
|
||||
}
|
||||
if !result.OK || !result.HasExpectedModel {
|
||||
t.Fatalf("CheckGatewayAccess() = %+v, want ok with expected model", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub2APIHostAdapterDeletesManagedResources(t *testing.T) {
|
||||
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
||||
requireAPIKey: true,
|
||||
version: "0.1.126",
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
if err := client.DeleteAccount(context.Background(), "account_1"); err != nil {
|
||||
t.Fatalf("DeleteAccount() error = %v", err)
|
||||
}
|
||||
if err := client.DeleteChannel(context.Background(), "channel_1"); err != nil {
|
||||
t.Fatalf("DeleteChannel() error = %v", err)
|
||||
}
|
||||
if err := client.DeleteGroup(context.Background(), "group_1"); err != nil {
|
||||
t.Fatalf("DeleteGroup() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub2APIHostAdapterListManagedResources(t *testing.T) {
|
||||
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
||||
requireAPIKey: true,
|
||||
version: "0.1.126",
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := client.ListManagedResources(context.Background(), sub2api.ListManagedResourcesRequest{
|
||||
GroupName: "crm-deepseek-group",
|
||||
ChannelName: "crm-deepseek-channel",
|
||||
PlanName: "crm-deepseek-plan",
|
||||
AccountNamePrefix: "deepseek-",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListManagedResources() error = %v", err)
|
||||
}
|
||||
|
||||
if len(snapshot.Groups) != 1 || snapshot.Groups[0].ID != "group_1" {
|
||||
t.Fatalf("Groups = %+v, want one group_1 match", snapshot.Groups)
|
||||
}
|
||||
if len(snapshot.Channels) != 2 || snapshot.Channels[0].ID != "channel_1" || snapshot.Channels[1].ID != "channel_2" {
|
||||
t.Fatalf("Channels = %+v, want two matching channels", snapshot.Channels)
|
||||
}
|
||||
if len(snapshot.Plans) != 1 || snapshot.Plans[0].ID != "plan_1" {
|
||||
t.Fatalf("Plans = %+v, want one plan_1 match", snapshot.Plans)
|
||||
}
|
||||
if len(snapshot.Accounts) != 2 || snapshot.Accounts[0].ID != "account_1" || snapshot.Accounts[1].ID != "account_2" {
|
||||
t.Fatalf("Accounts = %+v, want two deepseek account matches", snapshot.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
type sub2APIStubConfig struct {
|
||||
requireAPIKey bool
|
||||
version string
|
||||
@@ -220,52 +303,106 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server
|
||||
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"data": []map[string]any{
|
||||
{"id": "group_1", "name": "crm-deepseek-group"},
|
||||
{"id": "group_2", "name": "other-group"},
|
||||
},
|
||||
})
|
||||
case http.MethodPost:
|
||||
var payload map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["name"] == "" || payload["rate_multiplier"] == nil {
|
||||
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, http.StatusCreated, map[string]any{
|
||||
"data": map[string]any{
|
||||
"id": "group_1",
|
||||
"name": payload["name"],
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/v1/admin/groups/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodDelete {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var payload map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||
if payload["name"] == "" || payload["rate_multiplier"] == nil {
|
||||
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, http.StatusCreated, map[string]any{
|
||||
"data": map[string]any{
|
||||
"id": "group_1",
|
||||
"name": payload["name"],
|
||||
},
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/admin/channels", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"data": []map[string]any{
|
||||
{"id": "channel_1", "name": "crm-deepseek-channel"},
|
||||
{"id": "channel_2", "name": "crm-deepseek-channel"},
|
||||
{"id": "channel_3", "name": "other-channel"},
|
||||
},
|
||||
})
|
||||
case http.MethodPost:
|
||||
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/v1/admin/channels/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodDelete {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/admin/payment/plans", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"data": []map[string]any{
|
||||
{"id": "plan_1", "name": "crm-deepseek-plan"},
|
||||
{"id": "plan_2", "name": "other-plan"},
|
||||
},
|
||||
})
|
||||
case http.MethodPost:
|
||||
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
||||
})
|
||||
mux.HandleFunc("/api/v1/admin/accounts", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"data": []map[string]any{
|
||||
{"id": "account_1", "name": "deepseek-01"},
|
||||
{"id": "account_2", "name": "deepseek-02"},
|
||||
{"id": "account_3", "name": "other-01"},
|
||||
},
|
||||
})
|
||||
case http.MethodPost:
|
||||
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
||||
})
|
||||
mux.HandleFunc("/api/v1/admin/accounts/batch", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
||||
@@ -287,6 +424,14 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/admin/accounts/"), "/")
|
||||
if len(parts) == 1 {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -333,6 +478,19 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("x-api-key"); got != "user-api-key" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"data": []map[string]any{
|
||||
{"id": "deepseek-chat"},
|
||||
{"id": "deepseek-reasoner"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
@@ -108,8 +108,8 @@ func TestStoreInitRecordsMigrationLedgerOnce(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("first sqlite.Open() error = %v", err)
|
||||
}
|
||||
if got := countRows(t, store1.SQLDB(), "schema_migrations"); got != 1 {
|
||||
t.Fatalf("schema_migrations row count after first open = %d, want 1", got)
|
||||
if got := countRows(t, store1.SQLDB(), "schema_migrations"); got != 3 {
|
||||
t.Fatalf("schema_migrations row count after first open = %d, want 3", got)
|
||||
}
|
||||
if err := store1.Close(); err != nil {
|
||||
t.Fatalf("first store.Close() error = %v", err)
|
||||
@@ -121,8 +121,8 @@ func TestStoreInitRecordsMigrationLedgerOnce(t *testing.T) {
|
||||
}
|
||||
defer closeTestStore(t, store2)
|
||||
|
||||
if got := countRows(t, store2.SQLDB(), "schema_migrations"); got != 1 {
|
||||
t.Fatalf("schema_migrations row count after second open = %d, want 1", got)
|
||||
if got := countRows(t, store2.SQLDB(), "schema_migrations"); got != 3 {
|
||||
t.Fatalf("schema_migrations row count after second open = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +140,8 @@ func TestStoreInitBackfillsLedgerForCompletePreLedgerSchema(t *testing.T) {
|
||||
}
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
if got := countRows(t, store.SQLDB(), "schema_migrations"); got != 1 {
|
||||
t.Fatalf("schema_migrations row count after backfill = %d, want 1", got)
|
||||
if got := countRows(t, store.SQLDB(), "schema_migrations"); got != 3 {
|
||||
t.Fatalf("schema_migrations row count after backfill = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
141
tests/integration/store_runtime_test.go
Normal file
141
tests/integration/store_runtime_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestStoreRuntimeCreatesOperationalTables(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
for _, table := range []string{
|
||||
"hosts",
|
||||
"packs",
|
||||
"providers",
|
||||
"import_batches",
|
||||
"import_batch_items",
|
||||
"managed_resources",
|
||||
"probe_results",
|
||||
"access_closure_records",
|
||||
"reconcile_runs",
|
||||
} {
|
||||
if !tableExists(t, store.SQLDB(), table) {
|
||||
t.Fatalf("table %q does not exist after store initialization", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRuntimePersistsOperationalRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestStore(t)
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
hostID, err := store.Hosts().Create(ctx, sqlite.Host{
|
||||
HostID: "host-1",
|
||||
BaseURL: "https://sub2api.example.com",
|
||||
HostVersion: "0.1.126",
|
||||
CapabilityProbeJSON: `{"supports_batch_accounts":true}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Hosts().Create() error = %v", err)
|
||||
}
|
||||
|
||||
packID, err := store.Packs().Create(ctx, sqlite.Pack{
|
||||
PackID: "openai-cn-pack",
|
||||
Version: "1.0.0",
|
||||
Checksum: "checksum-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Packs().Create() error = %v", err)
|
||||
}
|
||||
|
||||
providerID, err := store.Providers().Create(ctx, sqlite.Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "deepseek",
|
||||
DisplayName: "DeepSeek",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Providers().Create() error = %v", err)
|
||||
}
|
||||
|
||||
batchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{
|
||||
HostID: hostID,
|
||||
PackID: packID,
|
||||
ProviderID: providerID,
|
||||
Mode: "strict",
|
||||
BatchStatus: "running",
|
||||
AccessStatus: "not_configured",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportBatches().Create() error = %v", err)
|
||||
}
|
||||
|
||||
itemID, err := store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{
|
||||
BatchID: batchID,
|
||||
KeyFingerprint: "fp-1",
|
||||
AccountStatus: "pending",
|
||||
ProbeSummaryJSON: `{}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportBatchItems().Create() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{
|
||||
BatchID: batchID,
|
||||
ResourceType: "group",
|
||||
HostResourceID: "group-1",
|
||||
ResourceName: "deepseek-group",
|
||||
}); err != nil {
|
||||
t.Fatalf("ManagedResources().Create() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.ProbeResults().Create(ctx, sqlite.ProbeResult{
|
||||
BatchItemID: itemID,
|
||||
ProbeType: "models",
|
||||
Status: "passed",
|
||||
SummaryJSON: `{"models":["deepseek-chat"]}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ProbeResults().Create() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{
|
||||
BatchID: batchID,
|
||||
ClosureType: "subscription",
|
||||
Status: "subscription_ready",
|
||||
DetailsJSON: `{"api_key_bound":true}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("AccessClosures().Create() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{
|
||||
ProviderID: providerID,
|
||||
Status: "drifted",
|
||||
SummaryJSON: `{"missing_resources":1}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReconcileRuns().Create() error = %v", err)
|
||||
}
|
||||
|
||||
if got := countRows(t, store.SQLDB(), "import_batches"); got != 1 {
|
||||
t.Fatalf("import_batches row count = %d, want 1", got)
|
||||
}
|
||||
if got := countRows(t, store.SQLDB(), "import_batch_items"); got != 1 {
|
||||
t.Fatalf("import_batch_items row count = %d, want 1", got)
|
||||
}
|
||||
if got := countRows(t, store.SQLDB(), "managed_resources"); got != 1 {
|
||||
t.Fatalf("managed_resources row count = %d, want 1", got)
|
||||
}
|
||||
if got := countRows(t, store.SQLDB(), "probe_results"); got != 1 {
|
||||
t.Fatalf("probe_results row count = %d, want 1", got)
|
||||
}
|
||||
if got := countRows(t, store.SQLDB(), "access_closure_records"); got != 1 {
|
||||
t.Fatalf("access_closure_records row count = %d, want 1", got)
|
||||
}
|
||||
if got := countRows(t, store.SQLDB(), "reconcile_runs"); got != 1 {
|
||||
t.Fatalf("reconcile_runs row count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user