Feat v0.6.15 user display audit (#50)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m10s
CI/CD / build-and-deploy (push) Successful in 1m31s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #50.
This commit is contained in:
2026-04-01 14:19:48 +00:00
committed by xcaliber
parent c9b9e68c18
commit d9802df2af
21 changed files with 542 additions and 35 deletions

View File

@@ -81,3 +81,38 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// ResolveUsers returns identity records for a batch of user IDs.
// GET /api/v1/users/resolve?ids=uuid1,uuid2,...
func (h *PresenceHandler) ResolveUsers(c *gin.Context) {
raw := strings.TrimSpace(c.Query("ids"))
if raw == "" {
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
return
}
ids := make([]string, 0)
for _, id := range strings.Split(raw, ",") {
id = strings.TrimSpace(id)
if id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
return
}
results, err := h.stores.Users.ResolveByIDs(c.Request.Context(), ids)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "resolve failed"})
return
}
// Build map keyed by user ID for O(1) client-side lookups
m := make(map[string]interface{}, len(results))
for _, u := range results {
m[u.ID] = u
}
c.JSON(http.StatusOK, gin.H{"data": m})
}

View File

@@ -0,0 +1,179 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"armature/config"
"armature/database"
"armature/middleware"
"armature/store"
postgres "armature/store/postgres"
sqlite "armature/store/sqlite"
)
// ── Presence/Resolve Test Harness ──────────────
type presenceHarness struct {
*testHarness
stores store.Stores
userToken string
userID string
}
func setupPresenceHarness(t *testing.T) *presenceHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
presence := NewPresenceHandler(stores)
protected.GET("/users/resolve", presence.ResolveUsers)
protected.GET("/users/search", presence.SearchUsers)
// Seed a caller user
userID := database.SeedTestUser(t, "caller", "caller@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "caller@test.com", "user")
return &presenceHarness{
testHarness: &testHarness{router: r, t: t},
stores: stores,
userToken: userToken,
userID: userID,
}
}
// ── Tests ──────────────────────────────────────
func TestResolveUsers_SingleID(t *testing.T) {
h := setupPresenceHarness(t)
// Seed a user with display_name
targetID := database.SeedTestUser(t, "alice", "alice@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, display_name = $1 WHERE id = $2"), "Alice Smith", targetID)
w := h.request("GET", "/api/v1/users/resolve?ids="+targetID, h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Data map[string]store.UserSearchResult `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
u, ok := resp.Data[targetID]
if !ok {
t.Fatalf("expected user %s in response, got %v", targetID, resp.Data)
}
if u.Username != "alice" {
t.Errorf("expected username 'alice', got %q", u.Username)
}
if u.DisplayName != "Alice Smith" {
t.Errorf("expected display_name 'Alice Smith', got %q", u.DisplayName)
}
}
func TestResolveUsers_MultipleIDs(t *testing.T) {
h := setupPresenceHarness(t)
id1 := database.SeedTestUser(t, "bob", "bob@test.com")
id2 := database.SeedTestUser(t, "carol", "carol@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), id1)
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, display_name = $1 WHERE id = $2"), "Carol D", id2)
w := h.request("GET", "/api/v1/users/resolve?ids="+id1+","+id2, h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Data map[string]store.UserSearchResult `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 2 {
t.Fatalf("expected 2 users, got %d", len(resp.Data))
}
if resp.Data[id1].Username != "bob" {
t.Errorf("expected bob, got %q", resp.Data[id1].Username)
}
if resp.Data[id2].DisplayName != "Carol D" {
t.Errorf("expected 'Carol D', got %q", resp.Data[id2].DisplayName)
}
}
func TestResolveUsers_MissingID(t *testing.T) {
h := setupPresenceHarness(t)
w := h.request("GET", "/api/v1/users/resolve?ids=00000000-0000-0000-0000-000000000099", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Data map[string]store.UserSearchResult `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 0 {
t.Errorf("expected empty map for missing ID, got %d entries", len(resp.Data))
}
}
func TestResolveUsers_EmptyParam(t *testing.T) {
h := setupPresenceHarness(t)
w := h.request("GET", "/api/v1/users/resolve?ids=", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Data map[string]interface{} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 0 {
t.Errorf("expected empty map, got %d entries", len(resp.Data))
}
}
func TestResolveUsers_NoParam(t *testing.T) {
h := setupPresenceHarness(t)
w := h.request("GET", "/api/v1/users/resolve", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Data map[string]interface{} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 0 {
t.Errorf("expected empty map, got %d entries", len(resp.Data))
}
}

View File

@@ -474,8 +474,9 @@ func main() {
protected.POST("/presence/heartbeat", presence.Heartbeat)
protected.GET("/presence", presence.Query)
// User search
// User search & resolve
protected.GET("/users/search", presence.SearchUsers)
protected.GET("/users/resolve", presence.ResolveUsers)
// Workflows
wfH := handlers.NewWorkflowHandler(stores)

View File

@@ -1355,6 +1355,42 @@ paths:
items:
type: object
/api/v1/users/resolve:
get:
summary: Resolve user identities by ID
operationId: resolveUsers
tags: [Presence]
parameters:
- name: ids
in: query
required: true
description: Comma-separated user IDs (max 100)
schema:
type: string
responses:
'200':
description: Map of user ID to identity record
content:
application/json:
schema:
type: object
properties:
data:
type: object
additionalProperties:
type: object
properties:
id:
type: string
username:
type: string
display_name:
type: string
handle:
type: string
avatar_url:
type: string
# ──────────────────────────────────────────────
# Notifications
# ──────────────────────────────────────────────

View File

@@ -103,6 +103,10 @@ type UserStore interface {
// Excludes the calling user. Max 20 results.
SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error)
// ResolveByIDs returns lightweight identity records for the given user IDs.
// Missing IDs are silently omitted. Max 100 IDs.
ResolveByIDs(ctx context.Context, ids []string) ([]UserSearchResult, error)
// ── CS2 additions ──
// CountAll returns the total number of users.
@@ -132,6 +136,7 @@ type UserSearchResult struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Handle string `json:"handle"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// =========================================

View File

@@ -250,6 +250,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
return results, rows.Err()
}
// ResolveByIDs returns lightweight identity records for the given user IDs.
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
if len(ids) == 0 {
return []store.UserSearchResult{}, nil
}
if len(ids) > 100 {
ids = ids[:100]
}
// Build $1,$2,... placeholders
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.UserSearchResult
for rows.Next() {
var u store.UserSearchResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
continue
}
results = append(results, u)
}
if results == nil {
results = []store.UserSearchResult{}
}
return results, rows.Err()
}
// ── CS2 additions ─────────────────────────────────────────────
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {

View File

@@ -262,6 +262,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
return results, rows.Err()
}
// ResolveByIDs returns lightweight identity records for the given user IDs.
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
if len(ids) == 0 {
return []store.UserSearchResult{}, nil
}
if len(ids) > 100 {
ids = ids[:100]
}
// Build ?,?,... placeholders
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.UserSearchResult
for rows.Next() {
var u store.UserSearchResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
continue
}
results = append(results, u)
}
if results == nil {
results = []store.UserSearchResult{}
}
return results, rows.Err()
}
// ── CS2 additions ─────────────────────────────────────────────
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {