Files
silo-server/internal/access/metadata_language_test.go
QuickandGitHub 73488d1bfa feat(metadata): add original-language preferences (#526)
* feat(metadata): add original-language preferences

* fix(settings): show metadata exceptions immediately

* docs(settings): add metadata language screenshot

* fix(settings): make language exceptions responsive

* fix(settings): standardize language display names
2026-07-31 15:23:30 -04:00

153 lines
4.9 KiB
Go

package access
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// settingStoreStub only answers the one method resolution reaches; the
// embedded nil interface panics on anything else, which is the point — this
// path must not touch the rest of the store.
type settingStoreStub struct {
userstore.UserStore
rows []userstore.SettingValue
err error
}
func (s settingStoreStub) ListSettingValuesForResolution(
context.Context, userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error) {
return s.rows, s.err
}
type capturingLogHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *capturingLogHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *capturingLogHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
h.records = append(h.records, r)
h.mu.Unlock()
return nil
}
func (h *capturingLogHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *capturingLogHandler) WithGroup(string) slog.Handler { return h }
func (h *capturingLogHandler) snapshot() []slog.Record {
h.mu.Lock()
defer h.mu.Unlock()
return append([]slog.Record(nil), h.records...)
}
func captureLogs(t *testing.T) *capturingLogHandler {
t.Helper()
handler := &capturingLogHandler{}
prev := slog.Default()
slog.SetDefault(slog.New(handler))
t.Cleanup(func() { slog.SetDefault(prev) })
return handler
}
// TestPreferredMetadataLanguageLogsStoreFailures pins the operator signal: the
// value deliberately degrades to "" on a store failure, but before the cutover
// it rode on the already-loaded profile row where a load failure was a hard
// error. A silent degrade would make transient pool exhaustion — or a
// persistently broken query path — indistinguishable from "no preference".
func TestPreferredMetadataLanguageLogsStoreFailures(t *testing.T) {
handler := captureLogs(t)
store := settingStoreStub{err: errors.New("connection pool exhausted")}
if got := PreferredMetadataLanguage(context.Background(), store, "profile-1"); got != "" {
t.Fatalf("degraded value = %q, want \"\"", got)
}
records := handler.snapshot()
if len(records) == 0 {
t.Fatal("a store failure resolved to the default with no log output")
}
record := records[0]
if record.Level < slog.LevelWarn {
t.Errorf("logged at %v, want at least WARN", record.Level)
}
var loggedError, loggedProfile bool
record.Attrs(func(a slog.Attr) bool {
switch a.Key {
case "error":
loggedError = strings.Contains(a.Value.String(), "connection pool exhausted")
case "profile_id":
loggedProfile = a.Value.String() == "profile-1"
}
return true
})
if !loggedError {
t.Errorf("log %q does not carry the store error", record.Message)
}
if !loggedProfile {
t.Errorf("log %q does not name the profile", record.Message)
}
}
// TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored: the healthy
// no-preference answer must not spam the log.
func TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored(t *testing.T) {
handler := captureLogs(t)
if got := PreferredMetadataLanguage(context.Background(), settingStoreStub{}, "profile-1"); got != "" {
t.Fatalf("no-preference value = %q, want \"\"", got)
}
if records := handler.snapshot(); len(records) != 0 {
t.Errorf("healthy resolution logged %d records, want none", len(records))
}
}
func TestResolveViewerPreferencesIncludesMetadataLanguageOverrides(t *testing.T) {
store := settingStoreStub{rows: []userstore.SettingValue{
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`[]`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`"en"`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguageOverrides, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`{"nor":"x-silo-original","JA":"de"}`),
},
}}
preferences := ResolveViewerPreferences(context.Background(), store, "profile-1")
if preferences.PreferredMetadataLanguage != "en" {
t.Fatalf("fallback language = %q, want en", preferences.PreferredMetadataLanguage)
}
want := map[string]string{"no": OriginalMetadataLanguage, "ja": "de"}
if len(preferences.MetadataLanguageOverrides) != len(want) {
t.Fatalf("metadata overrides = %#v, want %#v", preferences.MetadataLanguageOverrides, want)
}
for source, target := range want {
if preferences.MetadataLanguageOverrides[source] != target {
t.Errorf("metadata override %q = %q, want %q", source, preferences.MetadataLanguageOverrides[source], target)
}
}
}