From 21c7aa31ea1f5431192d994f5afb09dad49e8389 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 23:31:59 +0300 Subject: [PATCH] fix(backend): store an empty tag colour as NULL The PATCH "clear colour" path sent an empty string, which violates the hex CHECK constraint and never falls back to the category colour. Map '' to NULL via NULLIF in the tag insert/update so a cleared or omitted colour is stored as NULL. Co-Authored-By: Claude Opus 4.8 --- backend/internal/db/postgres/tag_repo.go | 4 +-- backend/internal/integration/server_test.go | 35 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/backend/internal/db/postgres/tag_repo.go b/backend/internal/db/postgres/tag_repo.go index 232c8ba..329db6e 100644 --- a/backend/internal/db/postgres/tag_repo.go +++ b/backend/internal/db/postgres/tag_repo.go @@ -272,7 +272,7 @@ func (r *TagRepo) Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error const query = ` WITH ins AS ( INSERT INTO data.tags (name, notes, color, category_id, metadata, creator_id, is_public) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7) RETURNING * ) SELECT @@ -321,7 +321,7 @@ WITH upd AS ( UPDATE data.tags SET name = $2, notes = $3, - color = $4, + color = NULLIF($4, ''), category_id = $5, metadata = COALESCE($6, metadata), is_public = $7 diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index a5819f6..fded83f 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -891,6 +891,41 @@ func TestRecordPoolView(t *testing.T) { require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String()) } +// TestTagColorOptional verifies a tag can be created without a colour (stored as +// NULL rather than the colour input's default) and that an existing colour can +// be cleared back to none. +func TestTagColorOptional(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + h := setupSuite(t) + adminToken := h.login("admin", "admin") + + // Created without a colour → color is null. + resp := h.doJSON("POST", "/tags", map[string]any{"name": "plain"}, adminToken) + require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String()) + var plain map[string]any + resp.decode(t, &plain) + assert.Nil(t, plain["color"], "tag created without a colour should have null color") + + // Created with a colour → kept verbatim. + resp = h.doJSON("POST", "/tags", map[string]any{"name": "red", "color": "aabbcc"}, adminToken) + require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String()) + var red map[string]any + resp.decode(t, &red) + assert.Equal(t, "aabbcc", red["color"]) + redID := red["id"].(string) + + // Clearing the colour (color: null) must store NULL — an empty string would + // violate the hex CHECK constraint and fail the update. + resp = h.doJSON("PATCH", "/tags/"+redID, map[string]any{"color": nil}, adminToken) + require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) + var cleared map[string]any + resp.decode(t, &cleared) + assert.Nil(t, cleared["color"], "cleared colour should be null") +} + // TestBulkTagAutoRule verifies the bulk add path also applies then_tags. func TestBulkTagAutoRule(t *testing.T) { if testing.Short() {