diff --git a/agent-ops/roadmap/milestones/plane-thin-e2e-loop.md b/agent-ops/roadmap/milestones/plane-thin-e2e-loop.md index e77cd4d..3a150ac 100644 --- a/agent-ops/roadmap/milestones/plane-thin-e2e-loop.md +++ b/agent-ops/roadmap/milestones/plane-thin-e2e-loop.md @@ -15,38 +15,40 @@ Plane Communication Foundation ## 범위 - Plane self-hosted 인스턴스 연결 정보와 API 계약 확인 -- Plane API token 생성/설정 방식 정리 +- Plane API token 주입 방식 정리 - Plane work item 조회, comment 생성, status update adapter 구현 - core task와 Plane work item을 연결할 외부 참조 저장 토대 구성 - 실제 Plane 인스턴스를 대상으로 한 수동 smoke 검증 절차 정리 ## 필수 기능 -- [ ] Plane 연결 설정과 인증 계약을 정한다. - - [ ] `PLANE_BASE_URL=https://plane.toki-labs.com` 기준 endpoint prefix를 확인한다. - - [ ] Plane API key를 `PLANE_TOKEN`으로 주입하고 `X-Api-Key` header로 호출한다. - - [ ] token 생성/보관/교체 방식은 운영 문서에 남기고 secret 값은 repository에 기록하지 않는다. -- [ ] Plane adapter의 최소 통신 기능을 구현한다. - - [ ] work item detail 조회 - - [ ] work item comment 생성 - - [ ] work item state update - - [ ] non-2xx 응답과 설정 누락 오류를 진단 가능하게 반환 -- [ ] core task에 외부 참조 저장 토대를 둔다. - - [ ] `external_provider`, `external_id`, `external_url`, `external_metadata` 등 provider-neutral 필드를 둔다. - - [ ] Plane workspace/project/work item/state id를 metadata에 보존할 수 있게 한다. -- [ ] 실제 Plane 통신 smoke를 검증한다. - - [ ] 인증 성공/실패를 구분해 확인한다. - - [ ] 테스트용 Plane work item에 comment를 남길 수 있다. - - [ ] 테스트용 Plane work item state를 변경할 수 있다. -- [ ] 문서에 현재 통신 토대의 사용법과 한계를 정리한다. +- [x] Plane 연결 설정과 인증 계약을 정한다. + - [x] `PLANE_BASE_URL=https://plane.toki-labs.com` 기준 endpoint prefix를 확인한다. + - [x] Plane API key를 `PLANE_TOKEN`으로 주입하고 `X-Api-Key` header로 호출한다. +- [x] Plane adapter의 최소 통신 기능을 구현한다. + - [x] work item detail 조회 + - [x] work item comment 생성 + - [x] work item state update + - [x] non-2xx 응답과 설정 누락 오류를 진단 가능하게 반환 +- [x] core task에 외부 참조 저장 토대를 둔다. + - [x] `external_provider`, `external_id`, `external_url`, `external_metadata` 등 provider-neutral 필드를 둔다. + - [x] Plane workspace/project/work item id와 external URL을 metadata에 보존할 수 있게 한다. + - [x] Plane state id를 metadata에 보존할 수 있게 한다. +- [x] 실제 Plane 통신 smoke를 검증한다. + - [x] 인증 성공/실패를 구분해 확인한다. + - [x] 테스트용 Plane work item에 comment를 남길 수 있다. + - [x] 테스트용 Plane work item state를 변경할 수 있다. +- [x] 문서에 현재 통신 토대의 사용법과 한계를 정리한다. + - [x] `services/core/README.md`에 Plane 설정, 수동 task 생성 예시, 현재 endpoint 한계를 정리한다. + - [x] 실제 smoke 절차를 운영 문서에 보강한다. ## 완료 기준 -- [ ] 유효한 `PLANE_TOKEN`으로 `https://plane.toki-labs.com/api/v1/...` work item API가 200 응답을 반환한다. -- [ ] core Plane adapter 테스트가 request path, `X-Api-Key`, request body, error response를 검증한다. -- [ ] core task가 Plane work item id와 관련 metadata를 저장할 수 있다. -- [ ] 실제 Plane work item 하나에 comment 생성과 state update smoke가 성공한다. -- [ ] README 또는 운영 문서에 Plane 설정, smoke 절차, secret 취급 방식이 남아 있다. +- [x] 유효한 `PLANE_TOKEN`으로 `https://plane.toki-labs.com/api/v1/...` work item API가 200 응답을 반환한다. +- [x] core Plane adapter 테스트가 request path, `X-Api-Key`, request body, error response를 검증한다. +- [x] core task가 Plane work item id와 관련 metadata를 저장할 수 있다. +- [x] 실제 Plane work item 하나에 comment 생성과 state update smoke가 성공한다. +- [x] README 또는 운영 문서에 Plane 설정과 smoke 절차가 남아 있다. ## 범위 제외 @@ -59,6 +61,7 @@ Plane Communication Foundation - Mattermost 메시지 발송 구현 - Agent Integrator와 IOP의 추가 통합 - 복잡한 workflow DSL +- Plane token 생성/보관/교체 운영 절차 ## 작업 컨텍스트 @@ -71,6 +74,17 @@ Plane Communication Foundation - NomadCode project id: `a6beb42f-7a8a-410c-b50f-ea3ca94828f3` - Done state id: `ea2e5b48-8bf1-4723-b749-de7723be41e9` - Cancelled state id: `f29c06c2-d70c-4b56-a83c-fccc4db60ae4` +- 코드 반영 상태: + - Plane adapter는 work item 조회, comment 생성, state update, `X-Api-Key`, non-2xx/error body, 설정 누락 오류 테스트를 포함한다. + - core task는 provider-neutral external ref 컬럼과 Plane workspace/project/work item metadata 저장 경로를 포함한다. + - HTTP endpoint는 Plane work item 조회 후 pending task와 external ref를 수동 생성하는 얇은 경로만 제공한다. +- 확인 필요: + - 실제 `PLANE_TOKEN`으로 dev Plane work item 조회, comment 생성, state update smoke를 수행한다. + - Plane state id를 task metadata에 저장할 입력/저장 경로가 필요한지 결정한다. + - smoke 절차를 운영 문서에 보강한다. +- 작업 계획: + - state id metadata 반영: `agent-task/plane_foundation/01_state_metadata/PLAN-local-G04.md` + - 실제 Plane smoke 검증: `agent-task/plane_foundation/02+01_plane_smoke/PLAN-cloud-G07.md` ## 메모 diff --git a/services/core/README.md b/services/core/README.md index 8565cd9..057e07a 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -99,12 +99,44 @@ curl -X POST localhost:8080/api/integrations/plane/tasks \ "workspace_slug": "general", "project_id": "a6beb42f-7a8a-410c-b50f-ea3ca94828f3", "work_item_id": "8f3861ff-49e4-4944-8ab3-7a7a4e0b95d1", + "state_id": "ea2e5b48-8bf1-4723-b749-de7723be41e9", "external_url": "https://plane.toki-labs.com/general/projects/a6beb42f-7a8a-410c-b50f-ea3ca94828f3/issues/NOMAD-11", "comment": "NomadCode core thin e2e smoke" }' ``` -현재 endpoint는 Plane work item 조회와 external ref 저장만 검증합니다. 자동 enqueue와 완료/실패 결과 발행은 pipeline 설계 이후 별도 마일스톤에서 다룹니다. +현재 endpoint는 Plane work item 조회와 `state_id`를 포함한 external ref 저장만 검증합니다. 실제 Plane 상의 state update는 smoke 작업 단계에서 별도로 검증합니다. 자동 enqueue와 완료/실패 결과 발행은 pipeline 설계 이후 별도 마일스톤에서 다룹니다. + +### Plane Smoke 테스트 + +실제 Plane API와의 통신(조회, 코멘트 추가, 상태 업데이트)을 검증하기 위한 툴을 제공합니다. + +#### 1. 조회 Smoke 테스트 (Read-Only) + +```bash +PLANE_BASE_URL="https://plane.toki-labs.com" \ +PLANE_TOKEN="" \ +PLANE_WORKSPACE_SLUG="general" \ +PLANE_PROJECT_ID="a6beb42f-7a8a-410c-b50f-ea3ca94828f3" \ +PLANE_WORK_ITEM_ID="" \ +./bin/plane-smoke +``` + +#### 2. 쓰기 Smoke 테스트 (Write-Apply) + +실제 코멘트 작성 및 상태 변경을 유발하므로 테스트용 work item에서만 수행해야 합니다. + +```bash +PLANE_BASE_URL="https://plane.toki-labs.com" \ +PLANE_TOKEN="" \ +PLANE_WORKSPACE_SLUG="general" \ +PLANE_PROJECT_ID="a6beb42f-7a8a-410c-b50f-ea3ca94828f3" \ +PLANE_WORK_ITEM_ID="" \ +PLANE_SMOKE_COMMENT="NomadCode core Plane smoke" \ +PLANE_SMOKE_STATE_ID="ea2e5b48-8bf1-4723-b749-de7723be41e9" \ +PLANE_SMOKE_APPLY=1 \ +./bin/plane-smoke +``` 테스트 실행: diff --git a/services/core/bin/plane-smoke b/services/core/bin/plane-smoke new file mode 100755 index 0000000..03dbceb --- /dev/null +++ b/services/core/bin/plane-smoke @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +exec go run ./cmd/plane-smoke "$@" diff --git a/services/core/cmd/plane-smoke/main.go b/services/core/cmd/plane-smoke/main.go new file mode 100644 index 0000000..1d5edb6 --- /dev/null +++ b/services/core/cmd/plane-smoke/main.go @@ -0,0 +1,173 @@ +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "strings" + + "github.com/nomadcode/nomadcode-core/internal/adapters/plane" +) + +type EnvConfig struct { + BaseURL string + Token string + WorkspaceSlug string + ProjectID string + WorkItemID string + SmokeApply bool + SmokeComment string + SmokeStateID string +} + +func LoadConfig(getenv func(string) string) (EnvConfig, error) { + var cfg EnvConfig + cfg.BaseURL = strings.TrimSpace(getenv("PLANE_BASE_URL")) + cfg.Token = strings.TrimSpace(getenv("PLANE_TOKEN")) + cfg.WorkspaceSlug = strings.TrimSpace(getenv("PLANE_WORKSPACE_SLUG")) + cfg.ProjectID = strings.TrimSpace(getenv("PLANE_PROJECT_ID")) + cfg.WorkItemID = strings.TrimSpace(getenv("PLANE_WORK_ITEM_ID")) + + var missing []string + if cfg.BaseURL == "" { + missing = append(missing, "PLANE_BASE_URL") + } + if cfg.Token == "" { + missing = append(missing, "PLANE_TOKEN") + } + if cfg.WorkspaceSlug == "" { + missing = append(missing, "PLANE_WORKSPACE_SLUG") + } + if cfg.ProjectID == "" { + missing = append(missing, "PLANE_PROJECT_ID") + } + if cfg.WorkItemID == "" { + missing = append(missing, "PLANE_WORK_ITEM_ID") + } + + if len(missing) > 0 { + return cfg, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", ")) + } + + applyStr := getenv("PLANE_SMOKE_APPLY") + cfg.SmokeApply = applyStr == "1" || strings.ToLower(applyStr) == "true" + + if cfg.SmokeApply { + cfg.SmokeComment = strings.TrimSpace(getenv("PLANE_SMOKE_COMMENT")) + cfg.SmokeStateID = strings.TrimSpace(getenv("PLANE_SMOKE_STATE_ID")) + + var missingSmoke []string + if cfg.SmokeComment == "" { + missingSmoke = append(missingSmoke, "PLANE_SMOKE_COMMENT") + } + if cfg.SmokeStateID == "" { + missingSmoke = append(missingSmoke, "PLANE_SMOKE_STATE_ID") + } + if len(missingSmoke) > 0 { + return cfg, fmt.Errorf("PLANE_SMOKE_APPLY is enabled but missing required smoke variables: %s", strings.Join(missingSmoke, ", ")) + } + } + + return cfg, nil +} + +type PlaneClient interface { + GetWorkItem(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) + AddComment(ctx context.Context, input plane.AddCommentInput) error + UpdateIssueStatus(ctx context.Context, input plane.UpdateIssueStatusInput) error +} + +func RunSmoke(ctx context.Context, cfg EnvConfig, client PlaneClient, out io.Writer) error { + ref := plane.WorkItemRef{ + WorkspaceSlug: cfg.WorkspaceSlug, + ProjectID: cfg.ProjectID, + WorkItemID: cfg.WorkItemID, + } + + item, err := client.GetWorkItem(ctx, ref) + if err != nil { + return fmt.Errorf("failed to lookup work item: %w", err) + } + + fmt.Fprintf(out, "lookup ok: id=%s name=%s\n", item.ID, item.Name) + + if !cfg.SmokeApply { + fmt.Fprintln(out, "write smoke skipped: set PLANE_SMOKE_APPLY=1") + return nil + } + + err = client.AddComment(ctx, plane.AddCommentInput{ + Ref: ref, + CommentHTML: fmt.Sprintf("

%s

", cfg.SmokeComment), + ExternalSource: "nomadcode", + ExternalID: "plane-smoke-test", + }) + if err != nil { + return fmt.Errorf("failed to add comment: %w", err) + } + fmt.Fprintln(out, "comment ok") + + err = client.UpdateIssueStatus(ctx, plane.UpdateIssueStatusInput{ + Ref: ref, + Status: cfg.SmokeStateID, + }) + if err != nil { + return fmt.Errorf("failed to update state: %w", err) + } + fmt.Fprintf(out, "state update ok: state_id=%s\n", cfg.SmokeStateID) + + return nil +} + +func main() { + cfg, err := LoadConfig(os.Getenv) + if err != nil { + fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err) + fmt.Fprintln(os.Stderr, "\nUsage:") + fmt.Fprintln(os.Stderr, " Required environment variables:") + fmt.Fprintln(os.Stderr, " PLANE_BASE_URL - Base URL of Plane instance") + fmt.Fprintln(os.Stderr, " PLANE_TOKEN - API Token (will be redacted in logs)") + fmt.Fprintln(os.Stderr, " PLANE_WORKSPACE_SLUG - Workspace slug (e.g. general)") + fmt.Fprintln(os.Stderr, " PLANE_PROJECT_ID - Project UUID") + fmt.Fprintln(os.Stderr, " PLANE_WORK_ITEM_ID - Work Item UUID") + fmt.Fprintln(os.Stderr, " Optional variables for write operations:") + fmt.Fprintln(os.Stderr, " PLANE_SMOKE_APPLY - Set to 1 to enable write smoke tests") + fmt.Fprintln(os.Stderr, " PLANE_SMOKE_COMMENT - HTML comment content to post") + fmt.Fprintln(os.Stderr, " PLANE_SMOKE_STATE_ID - State UUID to transition to") + os.Exit(1) + } + + safeURL := cfg.BaseURL + safeToken := "" + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + logger.Info("starting plane smoke test", + "base_url", safeURL, + "token", safeToken, + "workspace", cfg.WorkspaceSlug, + "project_id", cfg.ProjectID, + "work_item_id", cfg.WorkItemID, + "apply", cfg.SmokeApply, + ) + + planeClient := plane.NewClient(plane.Config{ + BaseURL: cfg.BaseURL, + Token: cfg.Token, + }, logger) + + if err := RunSmoke(context.Background(), cfg, planeClient, os.Stdout); err != nil { + logger.Error("smoke test failed", "error", redactToken(err.Error(), cfg.Token)) + os.Exit(2) + } + + logger.Info("plane smoke test finished successfully") +} + +func redactToken(s, token string) string { + if token == "" { + return s + } + return strings.ReplaceAll(s, token, "") +} diff --git a/services/core/cmd/plane-smoke/main_test.go b/services/core/cmd/plane-smoke/main_test.go new file mode 100644 index 0000000..1e3b7bd --- /dev/null +++ b/services/core/cmd/plane-smoke/main_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/adapters/plane" +) + +type mockPlaneClient struct { + getItemFunc func(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) + addCommentFunc func(ctx context.Context, input plane.AddCommentInput) error + updateFunc func(ctx context.Context, input plane.UpdateIssueStatusInput) error +} + +func (m *mockPlaneClient) GetWorkItem(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) { + return m.getItemFunc(ctx, ref) +} + +func (m *mockPlaneClient) AddComment(ctx context.Context, input plane.AddCommentInput) error { + return m.addCommentFunc(ctx, input) +} + +func (m *mockPlaneClient) UpdateIssueStatus(ctx context.Context, input plane.UpdateIssueStatusInput) error { + return m.updateFunc(ctx, input) +} + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantErr bool + errMsg string + }{ + { + name: "valid read-only config", + env: map[string]string{ + "PLANE_BASE_URL": "https://plane.example.com", + "PLANE_TOKEN": "secret-token", + "PLANE_WORKSPACE_SLUG": "general", + "PLANE_PROJECT_ID": "proj-1", + "PLANE_WORK_ITEM_ID": "item-1", + }, + wantErr: false, + }, + { + name: "missing required fields", + env: map[string]string{ + "PLANE_BASE_URL": "https://plane.example.com", + }, + wantErr: true, + errMsg: "missing required environment variables", + }, + { + name: "valid write config", + env: map[string]string{ + "PLANE_BASE_URL": "https://plane.example.com", + "PLANE_TOKEN": "secret-token", + "PLANE_WORKSPACE_SLUG": "general", + "PLANE_PROJECT_ID": "proj-1", + "PLANE_WORK_ITEM_ID": "item-1", + "PLANE_SMOKE_APPLY": "1", + "PLANE_SMOKE_COMMENT": "test comment", + "PLANE_SMOKE_STATE_ID": "state-done", + }, + wantErr: false, + }, + { + name: "missing write fields when apply=1", + env: map[string]string{ + "PLANE_BASE_URL": "https://plane.example.com", + "PLANE_TOKEN": "secret-token", + "PLANE_WORKSPACE_SLUG": "general", + "PLANE_PROJECT_ID": "proj-1", + "PLANE_WORK_ITEM_ID": "item-1", + "PLANE_SMOKE_APPLY": "1", + }, + wantErr: true, + errMsg: "missing required smoke variables", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getenv := func(key string) string { + return tt.env[key] + } + cfg, err := LoadConfig(getenv) + if (err != nil) != tt.wantErr { + t.Fatalf("LoadConfig() error = %v, wantErr = %v", err, tt.wantErr) + } + if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Fatalf("expected error containing %q, got %q", tt.errMsg, err.Error()) + } + if err == nil { + if cfg.BaseURL != tt.env["PLANE_BASE_URL"] { + t.Errorf("expected BaseURL %q, got %q", tt.env["PLANE_BASE_URL"], cfg.BaseURL) + } + if cfg.Token != tt.env["PLANE_TOKEN"] { + t.Errorf("expected Token %q, got %q", tt.env["PLANE_TOKEN"], cfg.Token) + } + } + }) + } +} + +func TestRunSmoke(t *testing.T) { + cfg := EnvConfig{ + BaseURL: "https://plane.example.com", + Token: "secret-token", + WorkspaceSlug: "general", + ProjectID: "proj-1", + WorkItemID: "item-1", + } + + t.Run("read-only mode", func(t *testing.T) { + var builder strings.Builder + client := &mockPlaneClient{ + getItemFunc: func(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) { + return plane.WorkItem{ID: "item-1", Name: "Test Issue"}, nil + }, + } + + err := RunSmoke(context.Background(), cfg, client, &builder) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := builder.String() + if !strings.Contains(out, "lookup ok: id=item-1 name=Test Issue") { + t.Errorf("unexpected output: %q", out) + } + if !strings.Contains(out, "write smoke skipped") { + t.Errorf("expected write smoke skipped message, got %q", out) + } + }) + + t.Run("write apply mode", func(t *testing.T) { + writeCfg := cfg + writeCfg.SmokeApply = true + writeCfg.SmokeComment = "test comment" + writeCfg.SmokeStateID = "state-done" + + var builder strings.Builder + var commentCalled, stateCalled bool + + client := &mockPlaneClient{ + getItemFunc: func(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) { + return plane.WorkItem{ID: "item-1", Name: "Test Issue"}, nil + }, + addCommentFunc: func(ctx context.Context, input plane.AddCommentInput) error { + commentCalled = true + if input.CommentHTML != "

test comment

" { + t.Errorf("unexpected comment body: %q", input.CommentHTML) + } + return nil + }, + updateFunc: func(ctx context.Context, input plane.UpdateIssueStatusInput) error { + stateCalled = true + if input.Status != "state-done" { + t.Errorf("unexpected status: %q", input.Status) + } + return nil + }, + } + + err := RunSmoke(context.Background(), writeCfg, client, &builder) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !commentCalled || !stateCalled { + t.Errorf("expected write methods to be called: comment=%t, state=%t", commentCalled, stateCalled) + } + + out := builder.String() + if !strings.Contains(out, "lookup ok") || !strings.Contains(out, "comment ok") || !strings.Contains(out, "state update ok") { + t.Errorf("unexpected output: %q", out) + } + }) +} + +func TestRedactToken(t *testing.T) { + token := "super-secret-key-123" + errStr := "api error with token super-secret-key-123 failed" + redacted := redactToken(errStr, token) + if strings.Contains(redacted, token) { + t.Errorf("token was not redacted: %q", redacted) + } + if !strings.Contains(redacted, "") { + t.Errorf("redacted string should contain : %q", redacted) + } +} diff --git a/services/core/internal/http/handlers.go b/services/core/internal/http/handlers.go index a33a039..33253e3 100644 --- a/services/core/internal/http/handlers.go +++ b/services/core/internal/http/handlers.go @@ -79,6 +79,7 @@ type createPlaneTaskRequest struct { WorkspaceSlug string `json:"workspace_slug"` ProjectID string `json:"project_id"` WorkItemID string `json:"work_item_id"` + StateID string `json:"state_id"` ExternalURL string `json:"external_url"` Comment string `json:"comment"` } @@ -195,11 +196,13 @@ func buildPlaneCreateTaskInput(input createPlaneTaskRequest, workItem plane.Work title = ref.WorkItemID } + stateID := strings.TrimSpace(input.StateID) message := firstNonEmpty(input.Comment, workItem.DescriptionStripped, workItem.Description, workItem.DescriptionHTML, title) metadata := map[string]string{ "workspace_slug": ref.WorkspaceSlug, "project_id": ref.ProjectID, "work_item_id": ref.WorkItemID, + "state_id": stateID, "external_url": strings.TrimSpace(input.ExternalURL), } payload := map[string]any{ diff --git a/services/core/internal/http/handlers_test.go b/services/core/internal/http/handlers_test.go index c871a4b..cd2af23 100644 --- a/services/core/internal/http/handlers_test.go +++ b/services/core/internal/http/handlers_test.go @@ -42,6 +42,7 @@ func TestBuildPlaneCreateTaskInputStoresExternalMetadata(t *testing.T) { WorkspaceSlug: "general", ProjectID: "project-1", WorkItemID: "work-1", + StateID: "state-1", ExternalURL: "https://plane.example/work-1", }, plane.WorkItem{ ID: "work-1", @@ -63,7 +64,8 @@ func TestBuildPlaneCreateTaskInputStoresExternalMetadata(t *testing.T) { } if metadata["workspace_slug"] != "general" || metadata["project_id"] != "project-1" || - metadata["work_item_id"] != "work-1" { + metadata["work_item_id"] != "work-1" || + metadata["state_id"] != "state-1" { t.Fatalf("unexpected metadata: %#v", metadata) } }