feat(backend): record tag usage in filters to activity.tag_uses

Listing files with a tag filter now logs each referenced tag to
activity.tag_uses, flagging it included (positive) or excluded (negated
under an odd number of NOTs); the untagged pseudo-token is skipped. The
filter AST is reused to determine polarity, so grouped negations like
!(A|B) mark both tags excluded.

Recording happens only when a filter is first applied — not on cursor
pagination or an anchored return — so one browse counts once. The write
is best-effort and never fails the listing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:40:13 +03:00
parent 6a3bb9ff51
commit 73ae8a046f
6 changed files with 266 additions and 15 deletions
@@ -54,6 +54,7 @@ type harness struct {
server *httptest.Server
client *http.Client
importDir string
pool *pgxpool.Pool
}
// setupSuite creates an ephemeral database, runs migrations, wires the full
@@ -165,6 +166,7 @@ func setupSuite(t *testing.T) *harness {
server: srv,
client: srv.Client(),
importDir: importDir,
pool: pool,
}
}
@@ -192,6 +194,32 @@ func (h *harness) url(path string) string {
return h.server.URL + "/api/v1" + path
}
// tagUses returns all activity.tag_uses rows as tag_id (text) → is_included.
func (h *harness) tagUses(ctx context.Context) map[string]bool {
h.t.Helper()
rows, err := h.pool.Query(ctx, `SELECT tag_id::text, is_included FROM activity.tag_uses`)
require.NoError(h.t, err)
defer rows.Close()
out := make(map[string]bool)
for rows.Next() {
var id string
var included bool
require.NoError(h.t, rows.Scan(&id, &included))
out[id] = included
}
require.NoError(h.t, rows.Err())
return out
}
// countTagUses returns the number of rows in activity.tag_uses.
func (h *harness) countTagUses(ctx context.Context) int {
h.t.Helper()
var n int
require.NoError(h.t, h.pool.QueryRow(ctx, `SELECT count(*) FROM activity.tag_uses`).Scan(&n))
return n
}
func (h *harness) do(method, path string, body io.Reader, token string, contentType string) *testResponse {
h.t.Helper()
req, err := http.NewRequest(method, h.url(path), body)
@@ -718,6 +746,67 @@ func TestRecordFileView(t *testing.T) {
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
}
// TestRecordTagUses verifies that filtering files by tags logs to
// activity.tag_uses — included tags as is_included=true, negated ones as
// false — while an unfiltered listing and follow-up pagination record nothing.
func TestRecordTagUses(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
ctx := context.Background()
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/tags", map[string]any{"name": "sea"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var sea map[string]any
resp.decode(t, &sea)
seaID := sea["id"].(string)
resp = h.doJSON("POST", "/tags", map[string]any{"name": "sky"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var sky map[string]any
resp.decode(t, &sky)
skyID := sky["id"].(string)
// Two files both tagged "sea", so a paged {t=sea} listing has a second page.
for _, name := range []string{"a.jpg", "b.jpg"} {
f := h.uploadJPEG(adminToken, name)
resp = h.doJSON("PUT", "/files/"+f["id"].(string)+"/tags",
map[string]any{"tag_ids": []string{seaID}}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// An unfiltered listing must not touch tag_uses.
resp = h.doJSON("GET", "/files", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
require.Equal(t, 0, h.countTagUses(ctx), "unfiltered list should record nothing")
// Include "sea": {t=sea}, one item per page so a next_cursor comes back.
resp = h.doJSON("GET", "/files?limit=1&filter=%7Bt%3D"+seaID+"%7D", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page1 map[string]any
resp.decode(t, &page1)
nextCursor, _ := page1["next_cursor"].(string)
require.NotEmpty(t, nextCursor, "expected a next_cursor for page 2")
// Exclude "sky": {!,t=sky}
resp = h.doJSON("GET", "/files?filter=%7B%21%2Ct%3D"+skyID+"%7D", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
uses := h.tagUses(ctx)
require.Len(t, uses, 2, "expected one row per filtered tag")
assert.True(t, uses[seaID], "included tag should be is_included=true")
assert.False(t, uses[skyID], "negated tag should be is_included=false")
// Page 2 (cursor present) is pagination, not a fresh filter — no new row.
resp = h.doJSON("GET", "/files?limit=1&cursor="+nextCursor+"&filter=%7Bt%3D"+seaID+"%7D",
nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.Equal(t, 2, h.countTagUses(ctx), "pagination should not add tag_uses rows")
}
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
func TestBulkTagAutoRule(t *testing.T) {
if testing.Short() {