diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go new file mode 100644 index 0000000..23494d6 --- /dev/null +++ b/internal/app/integration_test.go @@ -0,0 +1,108 @@ +//go:build integration + +// Package app_test 中的 integration_test.go 端到端覆盖:bootstrap admin → +// 通过 HTTP 登录拿到 token → dispatcher 写一条通知 → 用 token 通过 HTTP +// 查询未读计数为 1。所有外部依赖只有一个 postgres 容器,由 testcontainers +// 启动。需要本机 Docker;跑命令: +// +// go test -tags=integration -count=1 ./internal/app/... +package app_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + tcpg "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/db" + "github.com/yan1h/agent-coding-workflow/internal/infra/logger" + "github.com/yan1h/agent-coding-workflow/internal/infra/notify" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" + "github.com/yan1h/agent-coding-workflow/internal/user" +) + +func TestEndToEnd_LoginAndDispatchNotification(t *testing.T) { + ctx := context.Background() + + container, err := tcpg.Run(ctx, "postgres:16-alpine", + tcpg.WithDatabase("acw"), + tcpg.WithUsername("acw"), + tcpg.WithPassword("acw"), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = container.Terminate(ctx) }) + + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + require.NoError(t, db.Migrate(dsn, "file://../../migrations")) + + pool, err := db.NewPool(ctx, dsn) + require.NoError(t, err) + t.Cleanup(pool.Close) + + log := logger.New(logger.Options{Format: logger.FormatJSON, Level: logger.LevelInfo}) + auditRec := audit.NewPostgresRecorder(pool) + + userRepo := user.NewPostgresRepository(pool) + userSvc := user.NewService(userRepo, auditRec) + + notifyRepo := notify.NewPostgresRepository(pool) + disp := notify.NewDispatcher(notifyRepo, log) + disp.Register(notify.NewDummyNotifier(log)) + + u, err := userSvc.Bootstrap(ctx, "admin@local", "password123") + require.NoError(t, err) + require.NotNil(t, u) + + r := chi.NewRouter() + r.Use(middleware.RequestID) + user.NewHandler(userSvc).Mount(r) + // userSvc 直接满足 middleware.SessionResolver;不需要任何函数适配器。 + notify.NewHandler(notifyRepo, userSvc).Mount(r) + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + body, _ := json.Marshal(map[string]string{"email": "admin@local", "password": "password123"}) + resp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var lr struct { + Token string `json:"token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&lr)) + _ = resp.Body.Close() + require.NotEmpty(t, lr.Token) + + require.NoError(t, disp.Dispatch(ctx, notify.Message{ + UserID: u.ID, + Topic: "test", + Severity: notify.SeverityInfo, + Title: "hello", + Body: "world", + })) + + require.Eventually(t, func() bool { + req, _ := http.NewRequest("GET", srv.URL+"/api/v1/notifications/unread-count", nil) + req.Header.Set("Authorization", "Bearer "+lr.Token) + r2, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer r2.Body.Close() + var out struct { + Count int `json:"count"` + } + _ = json.NewDecoder(r2.Body).Decode(&out) + return out.Count == 1 + }, 2*time.Second, 50*time.Millisecond, "expected unread count to reach 1 after dispatch") +}