mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* feat(runtime): unbind agents on runtime delete instead of destroying them Deleting a runtime archived its agents and then hard-deleted the rows, so the agents and every conversation with them disappeared — while the confirmation dialog said "archive", which a user reasonably reads as recoverable. Retiring a laptop is an ordinary action; losing the agents configured on it is not an ordinary consequence. An agent is now a persistent business object and a runtime is replaceable execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL` means unbound — orthogonal to archived — and the agent keeps its instructions, skills, chats, labels, channel installations, autopilots and task history. service.AgentReadiness already refused an agent with no runtime, so the scheduling safety gate needed no change. Two columns become nullable, not one. Without `agent_task_queue.runtime_id`, deleting the runtime still cascades the task history away (and task_message / task_usage / task_token with it), so the agents would survive with no record of anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined to history: an active task must always have a runtime, so claim / dispatch / delivery-CAS paths can never observe one without. It is written against completed_at rather than a status list so a future non-terminal status fails closed instead of slipping through. Two prerequisites this depends on: - 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent. It went unnoticed because the delete used to cascade those rows away; with the new CHECK it would abort the delete and make the runtime undeletable. - The channel-installation / label / chat-pin / invocation-target / draft-restore cleanups were scoped to "archived agents on this runtime". Archived user agents now survive, so that scope is narrowed to kind='system' — otherwise the fix would produce a subtler loss: agent alive, configuration wiped. Also removes the squad guard that refused (409) when an active squad's leader was an archived agent on the runtime, plus the archived-squad delete that existed only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted, so nothing needs to be given up to retire a machine. Autopilots are no longer paused either: their assignee survives, and a rebind restores them without the owner having to remember to re-enable. Reason codes: an unbound agent reports agent_runtime_required, not runtime_offline. The copy for runtime_offline tells users to reconnect a machine; an unbound agent has no machine to reconnect, and the fix is to bind a runtime. Chat's bare 409 string gains the same code so the composer can offer that action. API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so installed clients keep parsing and no gated two-release rollout is needed. The confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete still routes to it, and the compared expected_active_agent_ids set is unchanged — widening it would 409 every older client forever. Co-authored-by: multica-agent <github@multica.ai> * fix: make runtime unbinding recoverable Co-authored-by: multica-agent <github@multica.ai> * fix: address runtime unbind review nits Co-authored-by: multica-agent <github@multica.ai> * fix: resolve runtime unbind review blockers Co-authored-by: multica-agent <github@multica.ai> * fix(migrations): renumber runtime unbind after main merge Co-authored-by: multica-agent <github@multica.ai> * test(daemon): avoid late-request lease flake Co-authored-by: multica-agent <github@multica.ai> * test(autopilots): bind validation fixture runtime Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
477 lines
17 KiB
Go
477 lines
17 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
func TestRuntimeHandlersRejectMalformedRuntimeID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
handle func(http.ResponseWriter, *http.Request)
|
|
}{
|
|
{
|
|
name: "usage",
|
|
method: "GET",
|
|
path: "/api/runtimes/not-a-uuid/usage",
|
|
handle: testHandler.GetRuntimeUsage,
|
|
},
|
|
{
|
|
name: "task activity",
|
|
method: "GET",
|
|
path: "/api/runtimes/not-a-uuid/task-activity",
|
|
handle: testHandler.GetRuntimeTaskActivity,
|
|
},
|
|
{
|
|
name: "delete",
|
|
method: "DELETE",
|
|
path: "/api/runtimes/not-a-uuid",
|
|
handle: testHandler.DeleteAgentRuntime,
|
|
},
|
|
{
|
|
name: "unbind-agents-and-delete",
|
|
method: "POST",
|
|
path: "/api/runtimes/not-a-uuid/unbind-agents-and-delete",
|
|
handle: testHandler.UnbindAgentsAndDeleteRuntime,
|
|
},
|
|
{
|
|
// Legacy route kept for installed clients; same handler.
|
|
name: "archive-agents-and-delete",
|
|
method: "POST",
|
|
path: "/api/runtimes/not-a-uuid/archive-agents-and-delete",
|
|
handle: testHandler.UnbindAgentsAndDeleteRuntime,
|
|
},
|
|
{
|
|
name: "models",
|
|
method: "POST",
|
|
path: "/api/runtimes/not-a-uuid/models",
|
|
handle: testHandler.InitiateListModels,
|
|
},
|
|
{
|
|
name: "update",
|
|
method: "POST",
|
|
path: "/api/runtimes/not-a-uuid/update",
|
|
handle: testHandler.InitiateUpdate,
|
|
},
|
|
{
|
|
name: "local skills",
|
|
method: "POST",
|
|
path: "/api/runtimes/not-a-uuid/local-skills",
|
|
handle: testHandler.InitiateListLocalSkills,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest(tt.method, tt.path, nil)
|
|
req = withURLParam(req, "runtimeId", "not-a-uuid")
|
|
tt.handle(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: expected 400 for malformed runtimeId, got %d: %s", tt.name, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetRuntimeUsage_BucketsByUsageTime ensures a task that was enqueued on
|
|
// one calendar day but whose tokens were reported the next day (e.g. execution
|
|
// crossed midnight, or the task sat in the queue) is attributed to the day
|
|
// tokens were actually produced, not the enqueue day. It also verifies the
|
|
// ?days=N cutoff covers the full earliest calendar day, not just "now minus N
|
|
// days" which would clip the morning of that day.
|
|
func TestGetRuntimeUsage_BucketsByUsageTime(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Pick a runtime bound to the fixture workspace.
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent_runtime WHERE workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("fetch runtime: %v", err)
|
|
}
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("fetch agent: %v", err)
|
|
}
|
|
|
|
// Create an issue for the tasks to reference.
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, creator_id, creator_type)
|
|
VALUES ($1, 'runtime usage test', $2, 'member')
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// enqueued yesterday 23:58 UTC, finished today 00:05 UTC — tokens belong to today.
|
|
now := time.Now().UTC()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
yesterdayLate := today.Add(-2 * time.Minute)
|
|
todayEarly := today.Add(5 * time.Minute)
|
|
// Task that ran entirely yesterday around 05:00 — used to verify the
|
|
// ?days cutoff isn't clipping yesterday's morning.
|
|
yesterdayMorning := today.Add(-19 * time.Hour)
|
|
|
|
insertTaskWithUsage := func(enqueueAt, usageAt time.Time, inputTokens int64) string {
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, runtime_id, status, created_at)
|
|
VALUES ($1, $2, $3, 'completed', $4)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID, enqueueAt).Scan(&taskID); err != nil {
|
|
t.Fatalf("insert task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, created_at)
|
|
VALUES ($1, 'claude', 'claude-3-5-sonnet', $2, 0, $3)
|
|
`, taskID, inputTokens, usageAt); err != nil {
|
|
t.Fatalf("insert task_usage: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
})
|
|
return taskID
|
|
}
|
|
|
|
insertTaskWithUsage(yesterdayLate, todayEarly, 1000) // cross-midnight
|
|
insertTaskWithUsage(yesterdayMorning, yesterdayMorning, 2000) // full-day yesterday
|
|
|
|
// ListRuntimeUsage reads from task_usage_hourly,
|
|
// aggregated to per-(date, provider, model) at query time. The
|
|
// window function is idempotent, so re-running this test rewrites
|
|
// the same totals.
|
|
if _, err := testPool.Exec(ctx, `
|
|
SELECT rollup_task_usage_hourly_window('-infinity'::timestamptz, 'infinity'::timestamptz)
|
|
`); err != nil {
|
|
t.Fatalf("rollup window: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
// Hourly buckets touched by this test cover the two calendar
|
|
// days in fixture data (today and yesterday in UTC, which is
|
|
// what the test uses for `today` / `yesterday` mocks).
|
|
testPool.Exec(ctx, `
|
|
DELETE FROM task_usage_hourly
|
|
WHERE runtime_id = $1
|
|
AND DATE(bucket_hour AT TIME ZONE 'UTC') IN ($2::date, $3::date)
|
|
`, runtimeID, today, today.Add(-24*time.Hour))
|
|
})
|
|
|
|
// Call the handler with ?days=1 at whatever "now" is. That should include
|
|
// both today and yesterday in full.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/runtimes/"+runtimeID+"/usage?days=1", nil)
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.GetRuntimeUsage(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetRuntimeUsage: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp []RuntimeUsageResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
byDate := make(map[string]int64)
|
|
for _, r := range resp {
|
|
byDate[r.Date] += r.InputTokens
|
|
}
|
|
|
|
todayKey := today.Format("2006-01-02")
|
|
yesterdayKey := today.Add(-24 * time.Hour).Format("2006-01-02")
|
|
|
|
// Cross-midnight task must attribute to today (tu.created_at), not yesterday
|
|
// (atq.created_at). Before the fix this was 0 on today / 1000 on yesterday.
|
|
if byDate[todayKey] != 1000 {
|
|
t.Errorf("cross-midnight task: today bucket expected 1000 input tokens, got %d (full map: %v)", byDate[todayKey], byDate)
|
|
}
|
|
// Yesterday's morning task must still be included — this is what breaks
|
|
// when ?days=N is interpreted as a rolling window instead of calendar days.
|
|
if byDate[yesterdayKey] != 2000 {
|
|
t.Errorf("yesterday morning task: yesterday bucket expected 2000 input tokens, got %d (full map: %v)", byDate[yesterdayKey], byDate)
|
|
}
|
|
}
|
|
|
|
// TestListRuntimeUsageByAgent_MergesMixedCaseProvider proves the by-agent
|
|
// query folds historical mixed-case provider rows (written before the handler
|
|
// lowercased provider on write) into a single lowercased bucket via the
|
|
// query's LOWER(provider) normalization, instead of splitting them.
|
|
func TestListRuntimeUsageByAgent_MergesMixedCaseProvider(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
var runtimeID, agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent_runtime WHERE workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("fetch runtime: %v", err)
|
|
}
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("fetch agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, creator_id, creator_type)
|
|
VALUES ($1, 'mixed-case provider test', $2, 'member')
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
now := time.Now().UTC()
|
|
// Two tasks for the same agent/model under this runtime, reporting the
|
|
// same provider in different casing — 'Cursor' (legacy) and 'cursor' (new).
|
|
insert := func(provider string, input int64) {
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, runtime_id, status, created_at)
|
|
VALUES ($1, $2, $3, 'completed', $4)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID, now).Scan(&taskID); err != nil {
|
|
t.Fatalf("insert task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, created_at)
|
|
VALUES ($1, $2, 'mixed-case-model', $3, 0, $4)
|
|
`, taskID, provider, input, now); err != nil {
|
|
t.Fatalf("insert task_usage: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
}
|
|
insert("Cursor", 1000)
|
|
insert("cursor", 500)
|
|
|
|
rows, err := testHandler.Queries.ListRuntimeUsageByAgent(ctx, db.ListRuntimeUsageByAgentParams{
|
|
RuntimeID: parseUUID(runtimeID),
|
|
Since: pgtype.Timestamptz{Time: now.Add(-time.Hour), Valid: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListRuntimeUsageByAgent: %v", err)
|
|
}
|
|
|
|
var got []db.ListRuntimeUsageByAgentRow
|
|
for _, r := range rows {
|
|
if r.Model == "mixed-case-model" {
|
|
got = append(got, r)
|
|
}
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected mixed-case providers to merge into 1 row, got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].Provider != "cursor" {
|
|
t.Errorf("expected merged provider 'cursor', got %q", got[0].Provider)
|
|
}
|
|
if got[0].InputTokens != 1500 {
|
|
t.Errorf("expected merged input_tokens 1500, got %d", got[0].InputTokens)
|
|
}
|
|
}
|
|
|
|
// TestListRuntimeUsageBucketsByViewerTimezone proves the runtime trend reads
|
|
// bucket the day boundary in the VIEWER's tz (the argument passed to
|
|
// listRuntimeUsage). The viewer tz is Asia/Shanghai; assertions only pass if
|
|
// listRuntimeUsage applies that tz correctly.
|
|
func TestListRuntimeUsageBucketsByViewerTimezone(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := handlerTestRuntimeID(t)
|
|
|
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
|
if err != nil {
|
|
t.Fatalf("load location: %v", err)
|
|
}
|
|
cutoff := time.Date(2026, 5, 4, 0, 0, 0, 0, loc)
|
|
cutoffDate := cutoff.Format("2006-01-02")
|
|
extraDate := cutoff.AddDate(0, 0, -1).Format("2006-01-02")
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM task_usage_hourly WHERE runtime_id = $1 AND provider = 'cutoff-test'`, runtimeID)
|
|
})
|
|
|
|
// Seed task_usage_hourly directly with one bucket per Shanghai calendar
|
|
// day. Pick 04:00 local (= 20:00 UTC the previous day) to catch
|
|
// off-by-one tz-cutoff bugs.
|
|
var agentID pgtype.UUID
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent WHERE workspace_id = $1 ORDER BY id LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("pick fixture agent: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO task_usage_hourly (
|
|
bucket_hour, workspace_id, runtime_id, agent_id, project_id,
|
|
provider, model,
|
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, event_count
|
|
)
|
|
VALUES
|
|
(($1::date + interval '4 hours') AT TIME ZONE 'Asia/Shanghai', $3, $4, $5, NULL,
|
|
'cutoff-test', 'old-day', 111, 0, 0, 0, 1),
|
|
(($2::date + interval '4 hours') AT TIME ZONE 'Asia/Shanghai', $3, $4, $5, NULL,
|
|
'cutoff-test', 'cutoff-day', 222, 0, 0, 0, 1)
|
|
ON CONFLICT ON CONSTRAINT uq_task_usage_hourly_key DO UPDATE
|
|
SET input_tokens = EXCLUDED.input_tokens,
|
|
output_tokens = EXCLUDED.output_tokens,
|
|
cache_read_tokens = EXCLUDED.cache_read_tokens,
|
|
cache_write_tokens = EXCLUDED.cache_write_tokens,
|
|
event_count = EXCLUDED.event_count
|
|
`, extraDate, cutoffDate, testWorkspaceID, runtimeID, agentID); err != nil {
|
|
t.Fatalf("seed hourly rows: %v", err)
|
|
}
|
|
|
|
resp, err := testHandler.listRuntimeUsage(ctx, parseUUID(runtimeID), "Asia/Shanghai", pgtype.Timestamptz{
|
|
Time: cutoff,
|
|
Valid: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("listRuntimeUsage: %v", err)
|
|
}
|
|
byDate := make(map[string]int64)
|
|
for _, row := range resp {
|
|
if row.Provider == "cutoff-test" {
|
|
byDate[row.Date] += row.InputTokens
|
|
}
|
|
}
|
|
if byDate[cutoffDate] != 222 {
|
|
t.Fatalf("expected cutoff date %s to be included with 222 tokens, got map %v", cutoffDate, byDate)
|
|
}
|
|
if byDate[extraDate] != 0 {
|
|
t.Fatalf("expected extra date %s to be excluded, got map %v", extraDate, byDate)
|
|
}
|
|
}
|
|
|
|
// TestResolveViewingTZ covers the three legs of resolveViewingTZ:
|
|
// explicit `?tz=` query param, the authenticated user's stored
|
|
// user.timezone, and the UTC fallback when neither yields a valid zone.
|
|
func TestResolveViewingTZ(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
var userID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`INSERT INTO "user" (name, email, timezone)
|
|
VALUES ('TZ Resolve', 'tz-resolve@multica.ai', 'Asia/Tokyo') RETURNING id`,
|
|
).Scan(&userID); err != nil {
|
|
t.Fatalf("insert user: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, userID) })
|
|
|
|
// Explicit ?tz= wins over the stored preference.
|
|
req := newRequest("GET", "/api/dashboard/usage/daily?tz=America/New_York", nil)
|
|
req.Header.Set("X-User-ID", userID)
|
|
if got := testHandler.resolveViewingTZ(req); got != "America/New_York" {
|
|
t.Fatalf("query param: expected America/New_York, got %q", got)
|
|
}
|
|
|
|
// No ?tz= → falls back to the authenticated user's stored timezone.
|
|
req = newRequest("GET", "/api/dashboard/usage/daily", nil)
|
|
req.Header.Set("X-User-ID", userID)
|
|
if got := testHandler.resolveViewingTZ(req); got != "Asia/Tokyo" {
|
|
t.Fatalf("stored fallback: expected Asia/Tokyo, got %q", got)
|
|
}
|
|
|
|
// An unparseable ?tz= is ignored, falling through to the stored value.
|
|
req = newRequest("GET", "/api/dashboard/usage/daily?tz=Mars/Olympus", nil)
|
|
req.Header.Set("X-User-ID", userID)
|
|
if got := testHandler.resolveViewingTZ(req); got != "Asia/Tokyo" {
|
|
t.Fatalf("invalid query param: expected Asia/Tokyo fallback, got %q", got)
|
|
}
|
|
|
|
// No ?tz= and no stored value → UTC.
|
|
var bareUserID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`INSERT INTO "user" (name, email)
|
|
VALUES ('TZ Bare', 'tz-bare@multica.ai') RETURNING id`,
|
|
).Scan(&bareUserID); err != nil {
|
|
t.Fatalf("insert bare user: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, bareUserID) })
|
|
req = newRequest("GET", "/api/dashboard/usage/daily", nil)
|
|
req.Header.Set("X-User-ID", bareUserID)
|
|
if got := testHandler.resolveViewingTZ(req); got != "UTC" {
|
|
t.Fatalf("no preference: expected UTC, got %q", got)
|
|
}
|
|
|
|
// A stored timezone that is itself an invalid IANA zone — e.g. a row
|
|
// written before server-side validation existed — must fall through
|
|
// to UTC. Without the LoadLocation guard on the stored value this
|
|
// string would reach SQL `AT TIME ZONE` and 500 every dashboard read.
|
|
var badTZUserID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`INSERT INTO "user" (name, email, timezone)
|
|
VALUES ('TZ Bad', 'tz-bad@multica.ai', 'Bad/Zone') RETURNING id`,
|
|
).Scan(&badTZUserID); err != nil {
|
|
t.Fatalf("insert bad-tz user: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, badTZUserID) })
|
|
req = newRequest("GET", "/api/dashboard/usage/daily", nil)
|
|
req.Header.Set("X-User-ID", badTZUserID)
|
|
if got := testHandler.resolveViewingTZ(req); got != "UTC" {
|
|
t.Fatalf("invalid stored tz: expected UTC fallback, got %q", got)
|
|
}
|
|
|
|
// No X-User-ID header and no ?tz= — an unauthenticated caller has
|
|
// neither signal, so the resolver must return UTC without attempting
|
|
// (and panicking on) a GetUser lookup.
|
|
req = newRequest("GET", "/api/dashboard/usage/daily", nil)
|
|
if got := testHandler.resolveViewingTZ(req); got != "UTC" {
|
|
t.Fatalf("unauthenticated caller: expected UTC, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestRuntimeHeatmapEndpointsUseViewerTZ verifies that the hour-of-day
|
|
// heatmap endpoints (GetRuntimeUsageByHour and GetRuntimeTaskActivity) bucket
|
|
// in the viewer's tz supplied via ?tz= and return 200.
|
|
func TestRuntimeHeatmapEndpointsUseViewerTZ(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
runtimeID := handlerTestRuntimeID(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
handle func(http.ResponseWriter, *http.Request)
|
|
}{
|
|
{"usage by-hour", "/api/runtimes/" + runtimeID + "/usage/by-hour?tz=Asia/Shanghai", testHandler.GetRuntimeUsageByHour},
|
|
{"task activity", "/api/runtimes/" + runtimeID + "/activity?tz=Asia/Shanghai", testHandler.GetRuntimeTaskActivity},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", c.path, nil)
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
c.handle(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s: expected 200, got %d: %s", c.name, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|