Compare commits

...

2 Commits

Author SHA1 Message Date
lvxinsheng
a4c313c8f1 test: assert app-type html filter returns exactly html apps 2026-06-15 11:08:12 +08:00
lvxinsheng
a2c820643d test: add live e2e for apps +list 2026-06-12 20:08:50 +08:00
2 changed files with 129 additions and 3 deletions

View File

@@ -0,0 +1,125 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsListWorkflowAsUser exercises `apps +list` against the live service.
// +list is the only apps shortcut that is read-only AND requires no pre-existing
// app_id fixture, so it is the sole command in the domain that can be live-tested
// without leaking tenant state (apps has no +delete endpoint). All assertions are
// tenant-data-independent: the envelope/array shape is checked unconditionally,
// field-level contracts only when items are present. An empty app list is valid.
func TestAppsListWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
// assertListEnvelope checks the shared contract for every +list invocation:
// exit 0, ok:true envelope, and data.items is a JSON array. It returns the
// items result for scenario-specific follow-up assertions. Failure messages
// reference field paths only (not the full data envelope) so real tenant app
// names are not written into test logs.
assertListEnvelope := func(t *testing.T, result *clie2e.Result) gjson.Result {
t.Helper()
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
items := gjson.Get(result.Stdout, "data.items")
require.True(t, items.IsArray(), "data.items should be a JSON array")
return items
}
t.Run("default list", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list"},
DefaultAs: "user",
})
require.NoError(t, err)
items := assertListEnvelope(t, result)
// Field-level contract only when the tenant has at least one app: every
// item carries app_id + name, and the shortcut strips icon_url/created_at
// (apps_list.go projects them away before output). The loop is a no-op on
// an empty tenant, keeping the test non-flaky.
for _, item := range items.Array() {
assert.NotEmpty(t, item.Get("app_id").String(), "each item should have app_id")
assert.NotEmpty(t, item.Get("name").String(), "each item should have name")
assert.False(t, item.Get("icon_url").Exists(), "icon_url should be stripped from list output")
assert.False(t, item.Get("created_at").Exists(), "created_at should be stripped from list output")
}
})
t.Run("page size honored", func(t *testing.T) {
// Baseline uncapped list first: the page-size=1 cap is only a meaningful
// assertion when the tenant actually has >= 2 apps. On a near-empty tenant
// the cap is vacuously satisfied, so we skip the comparison rather than
// claim coverage we don't have.
baseline, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list"},
DefaultAs: "user",
})
require.NoError(t, err)
baselineItems := assertListEnvelope(t, baseline)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--page-size", "1"},
DefaultAs: "user",
})
require.NoError(t, err)
items := assertListEnvelope(t, result)
if len(baselineItems.Array()) >= 2 {
assert.LessOrEqual(t, len(items.Array()), 1, "page-size 1 should cap items at 1 when the tenant has multiple apps")
}
})
t.Run("keyword no match", func(t *testing.T) {
// A high-entropy keyword that cannot match any real app name; proves the
// keyword filter is accepted and returns a well-formed (typically empty)
// list rather than erroring.
keyword := "lark-cli-e2e-nomatch-" + clie2e.GenerateSuffix()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--keyword", keyword},
DefaultAs: "user",
})
require.NoError(t, err)
assertListEnvelope(t, result)
})
t.Run("ownership filter mine", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--ownership", "mine"},
DefaultAs: "user",
})
require.NoError(t, err)
assertListEnvelope(t, result)
})
t.Run("app-type filter html", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--app-type", "html"},
DefaultAs: "user",
})
require.NoError(t, err)
items := assertListEnvelope(t, result)
// When the server echoes app_type on items, the html filter must return
// exactly html apps. Conditional so the test does not assume the field is
// always present in the list response.
for _, item := range items.Array() {
if at := item.Get("app_type"); at.Exists() {
assert.Equal(t, "html", at.String(), "html filter should only return html apps when app_type is present")
}
}
})
}

View File

@@ -5,7 +5,7 @@
- Command coverage: 100% (9/9)
- API dry-run coverage: 100% (7/7 API-backed commands)
- Local E2E coverage: 100% (2/2 local-only commands)
- Live coverage: 0%
- Live coverage: 1/1 live-capable command (`apps +list`); all other commands blocked — see "Blocked" below
## Summary
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
@@ -17,8 +17,9 @@
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing a Miaoda Git PAT; no body; `app_id` query metadata included.
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
- `TestAppsListWorkflowAsUser`: live `apps +list` against the service. Five `t.Run` proof points — default list (envelope `ok:true`, `data.items` array, and when non-empty each item has `app_id`/`name` with `icon_url`/`created_at` stripped), `--page-size 1` caps items when the tenant has multiple apps, high-entropy `--keyword` returns a well-formed empty list, `--ownership mine` accepted, `--app-type html` never returns `full_stack`. All assertions are tenant-data-independent (empty list is valid); skips via `SkipWithoutUserToken` when no user credentials are present.
Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Revisit when the server exposes `DELETE /apps/{appId}`.
Blocked (live): every command except `apps +list` remains without live coverage. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so any create-and-cleanup workflow would leak tenant state. Every read command other than `+list` requires a `--app-id` (or a session/release id), and obtaining a real one means creating an app that cannot be cleaned up. A shared per-run fixture app was considered and rejected: with no delete path, each CI run would leave an orphaned app — a real and accumulating drain on Miaoda tenant resources. Only `apps +list` is read-only AND fixture-independent, so it is the sole live-covered command. Revisit the rest when the server exposes `DELETE /apps/{appId}`.
## Command Table
@@ -26,7 +27,7 @@ Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpo
| --- | --- | --- | --- | --- | --- |
| ✓ | apps +create | shortcut | apps_create_dryrun_test.go::TestAppsCreateDryRun | `--name`, `--app-type` (required, case-sensitive, `html`/`full_stack`), `--description`, `--icon-url` | live blocked: no +delete to clean up |
| ✓ | apps +update | shortcut | apps_update_dryrun_test.go::TestAppsUpdateDryRun | `--app-id`; at least one of `--name`/`--description` | live blocked: no +delete |
| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun | `--keyword`; `--ownership` (enum all/mine/shared); `--app-type` (enum html/full_stack); `--page-size` default 20; `--page-token` cursor | live blocked: needs tenant fixtures |
| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun; apps_list_workflow_test.go::TestAppsListWorkflowAsUser | `--keyword`; `--ownership` (enum all/mine/shared); `--app-type` (enum html/full_stack); `--page-size` default 20; `--page-token` cursor | live covered: read-only, no app_id fixture needed; tenant-data-independent assertions |
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |