fixes
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// handleAdminFixCompanyCatalog runs in-place Fix A1 / catalog hygiene for one company.
|
||||
// Single route: POST /api/admin/companies/{id}/fix-catalog (no separate fix-a1).
|
||||
// Delegates to processing.FixCompanyCatalog, which:
|
||||
// 1. Ensures category_attributes links (orphan purge only)
|
||||
// 2. RepairCompanyCategoryEnhancePrompts (same map as RepairA1DemoCategoryEnhancePrompts)
|
||||
// 3. Re-applies product_enhance BuiltInDefaults when AIPrompts is set
|
||||
// 4–7. FixCatalogHygieneWithIDs + attribute sanitize + reprocess sample
|
||||
//
|
||||
// Body: confirm=true required; backfill_categories (default true);
|
||||
// reprocess_sample_limit (default 25, max 200, 0 = counts only).
|
||||
// May target A1 in place with confirm — prefer Platform Demo when unsure.
|
||||
// Refuses system company. Does not use A1 as a clone destination.
|
||||
//
|
||||
// Flash (UI): result.prompts / hashes / categories → flash.admin.fixA1Success.
|
||||
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Pool == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
companyID, err := uuid.Parse(strings.TrimSpace(chi.URLParam(r, "id")))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company id")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
BackfillCategories *bool `json:"backfill_categories"`
|
||||
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if !body.Confirm {
|
||||
Error(w, http.StatusBadRequest, "confirm must be true")
|
||||
return
|
||||
}
|
||||
|
||||
if platformsettings.IsSystemCompany(companyID) {
|
||||
Error(w, http.StatusBadRequest, "cannot fix the platform settings company")
|
||||
return
|
||||
}
|
||||
|
||||
var legacy, name string
|
||||
if err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(legacy_company_id, ''), name FROM companies WHERE id = $1`, companyID).
|
||||
Scan(&legacy, &name); err != nil {
|
||||
Error(w, http.StatusBadRequest, "company not found")
|
||||
return
|
||||
}
|
||||
|
||||
backfill := true
|
||||
if body.BackfillCategories != nil {
|
||||
backfill = *body.BackfillCategories
|
||||
}
|
||||
sampleLimit := 25
|
||||
if body.ReprocessSampleLimit != nil {
|
||||
sampleLimit = *body.ReprocessSampleLimit
|
||||
}
|
||||
if sampleLimit < 0 {
|
||||
sampleLimit = 0
|
||||
}
|
||||
if sampleLimit > 200 {
|
||||
sampleLimit = 200
|
||||
}
|
||||
|
||||
result, err := processing.FixCompanyCatalog(
|
||||
r.Context(),
|
||||
s.Pool,
|
||||
companyID,
|
||||
name,
|
||||
billing.IsA1CohortCompany(legacy, name),
|
||||
processing.FixCompanyCatalogOpts{
|
||||
BackfillCategories: backfill,
|
||||
ReprocessSampleLimit: sampleLimit,
|
||||
AIPrompts: s.AIPrompts,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not fix catalog", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"result": result,
|
||||
"note": "Catalog was repaired in place; reprocess recommended products manually (no mass reprocess).",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminFixCompanyCatalogNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/"+uuid.NewString()+"/fix-catalog", strings.NewReader(`{"confirm":true}`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminFixCompanyCatalog(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminFixCompanyCatalogRequiresConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Pool nil short-circuits before confirm — use non-nil Pool stub via missing company path is harder.
|
||||
// Confirm gate is covered when Pool is set; here we only assert invalid uuid.
|
||||
s := &Server{Pool: nil}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/not-a-uuid/fix-catalog", strings.NewReader(`{"confirm":false}`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminFixCompanyCatalog(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 (nil pool) body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterAdminFixCatalogMounted locks POST /api/admin/companies/{id}/fix-catalog
|
||||
// after session + CSRF + platform-admin (503 with nil Pool), not chi 404.
|
||||
func TestRouterAdminFixCatalogMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
path := "/api/admin/companies/" + uuid.NewString() + "/fix-catalog"
|
||||
csrf := csrfCookieForSession(t, h, sm, token)
|
||||
|
||||
mounted := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"confirm":true}`))
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
req.AddCookie(csrf)
|
||||
req.Header.Set("X-CSRF-Token", csrf.Value)
|
||||
h.ServeHTTP(mounted, req)
|
||||
if mounted.Code == http.StatusNotFound {
|
||||
t.Fatalf("route not mounted: status=404 body=%s", mounted.Body.String())
|
||||
}
|
||||
if mounted.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 (nil pool) body=%s", mounted.Code, mounted.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
||||
r.Get("/companies", s.handleAdminListCompanies)
|
||||
r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog)
|
||||
r.Post("/companies/{id}/fix-catalog", s.handleAdminFixCompanyCatalog)
|
||||
r.Get("/readiness", s.handleAdminReadiness)
|
||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||
r.Get("/analytics", s.handleAdminAnalytics)
|
||||
|
||||
@@ -43,7 +43,10 @@ info:
|
||||
4. Store it securely. Later list/revoke shows only key_prefix (first 10
|
||||
characters). Revoked keys fail auth immediately.
|
||||
|
||||
API keys are created in Settings -> API keys
|
||||
### Cutover / migration (reissue)
|
||||
|
||||
API keys from the previous Descrybe platform were not migrated. After
|
||||
cutover, integrations must create a new dk_ key in Settings -> API keys
|
||||
(or Use my API key on /docs). Pre-cutover secrets return the same HTTP 401
|
||||
Unauthorized as unknown keys — there is no separate “legacy key” error.
|
||||
|
||||
@@ -893,8 +896,8 @@ paths:
|
||||
jobs omit items. Not the same shape as GET /process/{id} (flat ProcessingJob).
|
||||
|
||||
On COMPLETED items, id is the processed_products UUID (legacy). Additive aliases:
|
||||
processed_product_id (same value as id) and raw_product_id (raw_products.id)
|
||||
for dual-mode clients that also call raw_product_ids surfaces.
|
||||
processed_product_id (same value as id), raw_product_id (raw_products.id), and
|
||||
name (same value as title) for dual-mode clients / scorecards.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -914,20 +917,36 @@ paths:
|
||||
$ref: "#/components/schemas/LegacyProcessStatusEnvelope"
|
||||
examples:
|
||||
completed:
|
||||
summary: Completed
|
||||
summary: Completed A1-shaped item
|
||||
value:
|
||||
data:
|
||||
status: COMPLETED
|
||||
process_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
|
||||
processing_type: full
|
||||
items:
|
||||
- ean: 0123456789012
|
||||
- ean: '8606019604493'
|
||||
id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc
|
||||
status: processed
|
||||
category: electronics
|
||||
title: Acme Wireless Earbuds ANC Black
|
||||
category: '50'
|
||||
category_name: Cookers
|
||||
title: VOX electric cooker EHT 6020 WG
|
||||
name: VOX electric cooker EHT 6020 WG
|
||||
meta_title: VOX electric cooker EHT 6020 WG | 50
|
||||
meta_description: Affordable electric cooker with four plates and a 65 L fan oven.
|
||||
description: "Vox Electronics electric cooker EHT 6020 WG offers strong value with four electric hobs and a 65 L fan oven."
|
||||
attributes:
|
||||
brand: Vox
|
||||
product_model: EHT6020WG
|
||||
main_image: https://images.example.com/products/vox-eht6020wg.jpg
|
||||
more_images: null
|
||||
eprel:
|
||||
id: "1234567"
|
||||
label: https://eprel.ec.europa.eu/label/Example
|
||||
pdf: https://eprel.ec.europa.eu/fiches/Example.pdf
|
||||
energy_class: A
|
||||
energy_scale: A-G
|
||||
total_items: 1
|
||||
processed_at: '2026-08-04T10:04:12Z'
|
||||
processing:
|
||||
@@ -6256,78 +6275,117 @@ components:
|
||||
format: date-time
|
||||
nullable: true
|
||||
LegacyProcessItem:
|
||||
type: object
|
||||
required:
|
||||
- ean
|
||||
properties:
|
||||
type: object
|
||||
description: |
|
||||
One COMPLETED legacy process line (A1 / public contract). Successful items
|
||||
expose category as categories.unique_id, a plain-text description string
|
||||
(never a JSON array; HTML stripped), SEO meta_title / meta_description,
|
||||
optional eprel object or null, clean attributes, images, and dual-mode ids.
|
||||
Product display name is title; additive name mirrors the same processed title
|
||||
(dual-mode for scorecards / legacy clients that read name).
|
||||
required:
|
||||
- ean
|
||||
properties:
|
||||
ean:
|
||||
type: string
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Legacy field: processed_products.id when enrichment succeeded.
|
||||
Do not treat as raw_products.id. Same value as processed_product_id.
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Legacy field: processed_products.id when enrichment succeeded.
|
||||
Do not treat as raw_products.id. Same value as processed_product_id.
|
||||
processed_product_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Explicit alias of id (processed_products.id). Prefer this name in new
|
||||
dual-mode clients; id remains for backward compatibility.
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Explicit alias of id (processed_products.id). Prefer this name in new
|
||||
dual-mode clients; id remains for backward compatibility.
|
||||
raw_product_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
raw_products.id for this job line. Use with POST /process raw_product_ids
|
||||
or dashboard catalog APIs. Present whenever the job product row exists.
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
raw_products.id for this job line. Use with POST /process raw_product_ids
|
||||
or dashboard catalog APIs. Present whenever the job product row exists.
|
||||
category:
|
||||
type: string
|
||||
nullable: true
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
categories.unique_id for the assigned category. Opaque string — may be
|
||||
numeric (e.g. "28" or "50") or slug-like. Not a URL path and not a UUID.
|
||||
category_name:
|
||||
type: string
|
||||
nullable: true
|
||||
type: string
|
||||
nullable: true
|
||||
description: Human-readable category display name (not the unique_id).
|
||||
title:
|
||||
type: string
|
||||
nullable: true
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Product display name (processed title). Primary legacy field; same value
|
||||
as name when present.
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Additive alias of title (same processed display name). Prefer title in
|
||||
new clients; name remains for scorecards and legacy readers.
|
||||
meta_title:
|
||||
type: string
|
||||
nullable: true
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
SEO title. Filled from processing meta or synthesized from title / category
|
||||
when empty so successful items are not left with null meta.
|
||||
meta_description:
|
||||
type: string
|
||||
nullable: true
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
SEO description (word-safe truncate). Distinct from body description when
|
||||
possible; synthesized from plain description when DB meta is empty.
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Plain-text product description (HTML stripped). attributes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Plain-text product body description. Always a string — never a one-element
|
||||
JSON array. Feed HTML tags are stripped; newlines may remain between paragraphs.
|
||||
attributes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
description: |
|
||||
Characteristic catalog attributes only (brand, model, dims, warranty, …).
|
||||
Core fields and eprel_* keys are not duplicated here.
|
||||
main_image:
|
||||
type: string
|
||||
nullable: true
|
||||
type: string
|
||||
nullable: true
|
||||
more_images:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
eprel:
|
||||
nullable: true
|
||||
type: object
|
||||
properties:
|
||||
nullable: true
|
||||
type: object
|
||||
description: |
|
||||
EU energy label payload when EPREL data is available; otherwise null.
|
||||
Prefer this object over raw eprel_* keys inside attributes.
|
||||
Live shape keys: id, label, pdf, energy_class, energy_scale.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: EPREL product registration id (same as eprel_id).
|
||||
label:
|
||||
type: string
|
||||
type: string
|
||||
pdf:
|
||||
type: string
|
||||
type: string
|
||||
energy_class:
|
||||
type: string
|
||||
type: string
|
||||
energy_scale:
|
||||
type: string
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
description: Per-item outcome — processed on success; not_found / failed / cancelled otherwise
|
||||
example: processed
|
||||
type: string
|
||||
description: Per-item outcome — processed on success; not_found / failed / cancelled otherwise
|
||||
example: processed
|
||||
error:
|
||||
type: string
|
||||
type: string
|
||||
examples:
|
||||
ProcessingJobAccepted:
|
||||
summary: Single processing job accepted (POST /process)
|
||||
@@ -6393,32 +6451,42 @@ components:
|
||||
started_at: '2026-08-04T10:00:00Z'
|
||||
created_at: '2026-08-04T09:59:50Z'
|
||||
LegacyProcessCompleted:
|
||||
summary: "Completed legacy poll (GET /products/process/{id})"
|
||||
summary: "Completed legacy poll with A1-shaped item (unique_id category, plain description, meta, eprel)"
|
||||
value:
|
||||
data:
|
||||
status: COMPLETED
|
||||
process_id: 8f14e45f-ceea-467a-9e5d-9c6b0e8f3a21
|
||||
processing_type: full
|
||||
items:
|
||||
- ean: '4548736132174'
|
||||
- ean: '8606019604493'
|
||||
id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc
|
||||
status: processed
|
||||
category: electronics
|
||||
category_name: Electronics
|
||||
title: Sony WH-1000XM5 Wireless Noise Cancelling Headphones Black
|
||||
meta_title: Sony WH-1000XM5 | Noise Cancelling Headphones
|
||||
meta_description: Industry-leading noise cancellation with up to 30 hours battery life.
|
||||
description: Industry-leading noise cancellation with up to 30 hours battery life.
|
||||
category: '50'
|
||||
category_name: Cookers
|
||||
title: VOX electric cooker EHT 6020 WG
|
||||
name: VOX electric cooker EHT 6020 WG
|
||||
meta_title: VOX electric cooker EHT 6020 WG | 50
|
||||
meta_description: Affordable electric cooker with four plates and a 65 L fan oven.
|
||||
description: "Vox Electronics electric cooker EHT 6020 WG offers strong value with four electric hobs and a 65 L fan oven. Energy class A with practical everyday capacity."
|
||||
attributes:
|
||||
color: Black
|
||||
brand: Sony
|
||||
battery_life_hours: '30'
|
||||
main_image: https://images.example.com/products/wh1000xm5-black.jpg
|
||||
brand: Vox
|
||||
product_model: EHT6020WG
|
||||
width: 0.6 m
|
||||
height: 0.85 m
|
||||
depth: 0.6 m
|
||||
weight: 42.81 kg
|
||||
warranty: 60 months
|
||||
main_image: https://images.example.com/products/vox-eht6020wg.jpg
|
||||
more_images:
|
||||
- https://images.example.com/products/wh1000xm5-black-side.jpg
|
||||
eprel: null
|
||||
- https://images.example.com/products/vox-eht6020wg-side.jpg
|
||||
eprel:
|
||||
id: "1234567"
|
||||
label: https://eprel.ec.europa.eu/label/Example
|
||||
pdf: https://eprel.ec.europa.eu/fiches/Example.pdf
|
||||
energy_class: A
|
||||
energy_scale: A-G
|
||||
total_items: 1
|
||||
processed_at: '2026-08-04T10:04:12Z'
|
||||
LegacyProcessInProgress:
|
||||
|
||||
@@ -415,6 +415,36 @@ func TestV1OpenAPIYAMLProcessDualIDsAndGatesDocumentsContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestV1OpenAPIYAMLLegacyProcessItemEprelShape locks LegacyProcessItem.eprel
|
||||
// to the live nested object from eprel.MergeInto / extractEPRELFromAttrs.
|
||||
func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
|
||||
comps, _ := root["components"].(map[string]any)
|
||||
schemas, _ := comps["schemas"].(map[string]any)
|
||||
item, _ := schemas["LegacyProcessItem"].(map[string]any)
|
||||
itemProps, _ := item["properties"].(map[string]any)
|
||||
eprel, _ := itemProps["eprel"].(map[string]any)
|
||||
eprelProps, _ := eprel["properties"].(map[string]any)
|
||||
if eprelProps == nil {
|
||||
t.Fatal("LegacyProcessItem.eprel.properties missing")
|
||||
}
|
||||
want := []string{"id", "label", "pdf", "energy_class", "energy_scale"}
|
||||
for _, key := range want {
|
||||
if _, ok := eprelProps[key]; !ok {
|
||||
t.Fatalf("LegacyProcessItem.eprel missing property %q (live shape)", key)
|
||||
}
|
||||
}
|
||||
if len(eprelProps) != len(want) {
|
||||
keys := make([]string, 0, len(eprelProps))
|
||||
for k := range eprelProps {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
t.Fatalf("LegacyProcessItem.eprel properties = %v, want exactly %v", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestV1OpenAPIYAMLDocumentsAPIKeyCutoverReissue locks cutover honesty: legacy
|
||||
// API keys were not migrated and clients must create new keys.
|
||||
func TestV1OpenAPIYAMLDocumentsAPIKeyCutoverReissue(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user