Files
kandev/apps/backend/internal/plugins/host_data_dependencies_logging_test.go
2026-09-18 14:55:51 +00:00

156 lines
6.5 KiB
Go

package plugins
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/pkg/pluginsdk"
)
// newObservedLogger wires a *logger.Logger backed by a zaptest/observer
// core, so a test can assert on the fields of a specific log record.
func newObservedLogger(t *testing.T) (*logger.Logger, *observer.ObservedLogs) {
t.Helper()
core, logs := observer.New(zapcore.DebugLevel)
log, err := logger.NewFromZap(zap.New(core))
require.NoError(t, err)
return log, logs
}
// TestAttachDependencies_LogsWhenTaskDataSourceIsNil proves the
// AC-PLUGINS-TASK-DEPS-002.4 diagnostic fires for the "derivation source
// absent" failure class, carrying plugin, instance, resource type,
// operation, and result code — no task title, id, or edge content.
func TestAttachDependencies_LogsWhenTaskDataSourceIsNil(t *testing.T) {
log, logs := newObservedLogger(t)
host := &pluginHost{
pluginID: "plugin-1",
capabilities: manifest.Capabilities{APIRead: []string{"tasks"}},
log: log,
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetTask")
require.NoError(t, err)
entries := logs.All()
require.Len(t, entries, 1, "exactly one record for this derivation attempt")
fields := entries[0].ContextMap()
require.Equal(t, "plugin-1", fields["plugin_id"])
require.Equal(t, "plugin-1", fields["instance_id"], "a gRPC host has no separate instance id, so it falls back to plugin id")
require.Equal(t, "task", fields["resource_type"])
require.Equal(t, "GetTask", fields["operation"])
require.Equal(t, "source_unavailable", fields["result_code"])
}
// TestAttachDependencies_UsesCanvasInstanceIDWhenSet proves a canvas-scoped
// host logs its real instance id rather than falling back to plugin id.
func TestAttachDependencies_UsesCanvasInstanceIDWhenSet(t *testing.T) {
log, logs := newObservedLogger(t)
host := &pluginHost{
pluginID: "plugin-1",
instanceID: "instance-9",
capabilities: manifest.Capabilities{APIRead: []string{"tasks"}},
log: log,
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetWebAppTask")
require.NoError(t, err)
require.Len(t, logs.All(), 1)
require.Equal(t, "instance-9", logs.All()[0].ContextMap()["instance_id"])
}
// TestAttachDependencies_LogsOnceWhenDerivationWithheldAcrossManyTasks
// proves the log unit is the derivation attempt, not the withheld task: a
// batch-wide failure (every task's BlockedReason coming back "unknown")
// still produces exactly one record.
func TestAttachDependencies_LogsOnceWhenDerivationWithheldAcrossManyTasks(t *testing.T) {
log, logs := newObservedLogger(t)
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.host.log = log
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
"task-2": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
"task-3": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
}
tasks := []pluginsdk.Task{{ID: "task-1"}, {ID: "task-2"}, {ID: "task-3"}}
models := []*taskmodels.Task{{ID: "task-1"}, {ID: "task-2"}, {ID: "task-3"}}
err := d.host.attachDependencies(context.Background(), tasks, models, true, "ListTasks")
require.NoError(t, err)
entries := logs.All()
require.Len(t, entries, 1, "one record per derivation attempt, not one per withheld task")
require.Equal(t, "derivation_withheld", entries[0].ContextMap()["result_code"])
}
// TestAttachDependencies_NoLogOnSuccessfulDerivation proves an ordinary
// non-withheld read logs nothing.
func TestAttachDependencies_NoLogOnSuccessfulDerivation(t *testing.T) {
log, logs := newObservedLogger(t)
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.host.log = log
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetTask")
require.NoError(t, err)
require.Empty(t, logs.All())
}
// TestAttachDependencies_NoLogWhenCapabilityDenied proves a caller lacking
// api_read:tasks is not logged as a failed derivation attempt: per the
// design, withholding for lack of capability never reaches the derivation
// at all, so there is nothing to report as having failed.
func TestAttachDependencies_NoLogWhenCapabilityDenied(t *testing.T) {
log, logs := newObservedLogger(t)
d := newTestDataHost(manifest.Capabilities{})
d.host.log = log
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetTask")
require.NoError(t, err)
require.Empty(t, logs.All())
}
// TestAttachDependencies_NoLogOnFanOutRefusal proves the bounded fan-out
// refusal is not logged by this diagnostic: it is a distinct sentinel
// (ResourceExhausted before any task is serialized), never the withheld
// verdict AC-PLUGINS-TASK-DEPS-002.4 covers.
func TestAttachDependencies_NoLogOnFanOutRefusal(t *testing.T) {
log, logs := newObservedLogger(t)
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.host.log = log
d.tasks.dependencyViewsErr = taskservice.ErrDependencyFanOutExceeded
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, true, "ListTasks")
require.Error(t, err)
require.Empty(t, logs.All())
}
// TestAttachDependencies_NilLoggerNeverPanics proves a bare test/production
// host with no logger wired (log is nil) degrades to no-op logging instead
// of panicking, on both the source-absent and derivation-withheld paths.
func TestAttachDependencies_NilLoggerNeverPanics(t *testing.T) {
host := &pluginHost{capabilities: manifest.Capabilities{APIRead: []string{"tasks"}}}
tasks := []pluginsdk.Task{{ID: "task-1"}}
require.NotPanics(t, func() {
_ = host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetTask")
})
}