alby-hub/transactions/user_labels_test.go
René Aaron ed7e10cf25
feat: user labels for transactions (#2265)
* feat: user labels poc

* feat: backend for transaction user labels

Add PATCH /api/transactions/:paymentHash/label that merges a
user-supplied {key:value} map into the existing transaction metadata
under the user_label key, preserving NIP-47 fields. Empty map clears
the labels. Trims whitespace, drops blank rows, caps key/value length.

Wire the frontend editor to call the endpoint and revalidate the
transactions SWR cache on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: include user labels as columns in CSV export

Collect the union of user_label keys across all exported transactions
and emit each one as its own label_<key> column. The existing metadata
JSON column is preserved so importers like Raccoin keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: drop hardcoded label suggestions, autocomplete from prior keys

Open the editor with a single blank row instead of four pre-seeded
fields. Suggest label keys via a datalist populated from any
user_label keys already present in the SWR transactions cache, so
suggestions reflect the user's own taxonomy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: inline label editor and indicator-only list row

Replace the nested label dialog with an inline editor that toggles
within the existing transaction detail dialog, removing dialog
stacking. In the transactions list, replace per-label badges with a
single tag icon next to the timestamp so row height stays uniform; the
full labels remain visible in the detail view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: don't bump updated_at on metadata-only edits

GORM's Update auto-touches updated_at, which made the transactions
list reorder labeled transactions to the top. Switch
SetTransactionMetadata to UpdateColumn so only the metadata column
changes. Add regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: route PATCH transaction label requests in wails

The desktop app routes API calls through WailsRequestRouter rather
than HTTP. Add a handler for PATCH /api/transactions/:hash/label
before the existing transaction lookup so labels work in Wails too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: finalize backend

* chore: rename to labels

* chore: finalize frontend

* chore: use tx id to add user labels

* chore: address minor nits

* fix: linting

* fix: use explicit primary key lookups

* chore: simplify transaction csv label export

* fix(frontend): drop label count from transaction list badge

The count adds visual weight without informing any decision from the
list view. The icon-as-badge already signals labels exist; the actual
values are in the details dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): revert transaction list label indicator to bare icon

The Badge wrapper made the icon-only indicator wider than tall, which
looked off. Restores the pre-PR look — a small TagIcon next to the
timestamp — since the in-dialog editor is the place to see actual labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: im-adithya <imadithyavardhan@gmail.com>
2026-04-30 13:13:07 +02:00

164 lines
5.5 KiB
Go

package transactions
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/tests"
)
func setupTransactionForUserLabels(t *testing.T, transactionType string, initialMetadata map[string]interface{}) (*transactionsService, *tests.TestService, *db.Transaction) {
t.Helper()
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
preimage := tests.MockLNClientTransaction.Preimage
dbTransaction := &db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: transactionType,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &preimage,
AmountMsat: 123000,
}
if initialMetadata != nil {
metadataBytes, err := json.Marshal(initialMetadata)
require.NoError(t, err)
dbTransaction.Metadata = datatypes.JSON(metadataBytes)
}
require.NoError(t, svc.DB.Create(dbTransaction).Error)
return NewTransactionsService(svc.DB, svc.EventPublisher), svc, dbTransaction
}
func loadTransactionMetadata(t *testing.T, svc *tests.TestService, id uint) map[string]interface{} {
t.Helper()
var dbTransaction db.Transaction
require.NoError(t, svc.DB.First(&dbTransaction, id).Error)
if dbTransaction.Metadata == nil {
return nil
}
metadata := map[string]interface{}{}
require.NoError(t, json.Unmarshal(dbTransaction.Metadata, &metadata))
return metadata
}
func TestSetTransactionUserLabels_ValidLabels(t *testing.T) {
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, nil)
defer svc.Remove()
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{
"description": "top up PPQ.AI",
"counterparty": "PPQ.AI",
})
require.NoError(t, err)
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
labels, ok := metadata["user_labels"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "top up PPQ.AI", labels["description"])
assert.Equal(t, "PPQ.AI", labels["counterparty"])
}
func TestSetTransactionUserLabels_IncomingTransaction(t *testing.T) {
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_INCOMING, nil)
defer svc.Remove()
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{
"source": "customer",
})
require.NoError(t, err)
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
labels, ok := metadata["user_labels"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "customer", labels["source"])
}
func TestSetTransactionUserLabels_PreservesExistingMetadata(t *testing.T) {
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, map[string]interface{}{
"comment": "hello",
"nostr": map[string]interface{}{"pubkey": "abcdef"},
})
defer svc.Remove()
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{
"account": "sponsoring",
})
require.NoError(t, err)
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
assert.Equal(t, "hello", metadata["comment"])
assert.NotNil(t, metadata["nostr"])
labels, ok := metadata["user_labels"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "sponsoring", labels["account"])
}
func TestSetTransactionUserLabels_ClearsLabels(t *testing.T) {
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, map[string]interface{}{
"comment": "hello",
"user_labels": map[string]interface{}{"description": "old"},
})
defer svc.Remove()
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{})
require.NoError(t, err)
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
_, exists := metadata["user_labels"]
assert.False(t, exists)
assert.Equal(t, "hello", metadata["comment"])
}
func TestSetTransactionUserLabels_TrimsAndDropsBlankLabels(t *testing.T) {
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, nil)
defer svc.Remove()
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, map[string]string{
" account ": " sponsoring ",
"empty": "",
"": "orphan",
})
require.NoError(t, err)
metadata := loadTransactionMetadata(t, svc, dbTransaction.ID)
labels, ok := metadata["user_labels"].(map[string]interface{})
require.True(t, ok)
assert.Len(t, labels, 1)
assert.Equal(t, "sponsoring", labels["account"])
}
func TestSetTransactionUserLabels_RejectsOversizedMetadata(t *testing.T) {
transactionsService, svc, dbTransaction := setupTransactionForUserLabels(t, constants.TRANSACTION_TYPE_OUTGOING, nil)
defer svc.Remove()
labels := map[string]string{
"description": strings.Repeat("a", constants.INVOICE_METADATA_MAX_LENGTH),
}
err := transactionsService.SetTransactionUserLabels(context.TODO(), dbTransaction.ID, labels)
require.Error(t, err)
encodedMetadata, marshalErr := json.Marshal(map[string]interface{}{
"user_labels": labels,
})
require.NoError(t, marshalErr)
assert.Equal(t,
fmt.Sprintf("encoded invoice metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(encodedMetadata)),
err.Error(),
)
}