Files
OpenList/drivers/github/mtime_test.go
jiwangyihao d12bd4c71e feat(drivers/github): add optional accurate modified time (#2388)
* feat(drivers/github): add optional accurate modified time

* refactor(github): 简化准确修改时间查询

- 将最多 200 个路径合并到一次 GraphQL history 查询,并用 ^{commit} 统一解析 ref

- 删除分批和冗余响应解析,保留失败降级、tree fallback 与缓存行为

- 使用 JSON 字符串字面量支持控制字符路径,并收敛行为测试

---------

Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-29 01:31:11 +08:00

379 lines
14 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
odriver "github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/go-resty/resty/v2"
)
func TestDriverInfoIncludesAccurateModifiedTimeDefault(t *testing.T) {
info := op.GetDriverInfoMap()["GitHub API"]
for _, item := range info.Additional {
if item.Name != "accurate_modified_time" {
continue
}
if item.Default != "false" {
t.Fatalf("unexpected default: %q", item.Default)
}
return
}
t.Fatal("accurate_modified_time item not registered")
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func newGithubTestDriver(rt roundTripFunc, token string, enabled bool) *Github {
return &Github{
Storage: model.Storage{MountPath: "/github-test", CacheExpiration: 10},
Addition: Addition{
RootPath: odriver.RootPath{RootFolderPath: "/"},
Token: token,
Owner: "owner",
Repo: "repo",
Ref: "main",
AccurateModifiedTime: enabled,
},
client: resty.New().SetTransport(rt),
}
}
func newJSONResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json: %v", err)
}
return string(data)
}
func graphQLQueryFromRequest(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read graphql request body: %v", err)
}
var payload map[string]string
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode graphql request body: %v", err)
}
query := payload["query"]
if query == "" {
t.Fatalf("graphql request missing query: %s", string(body))
}
return query
}
func newContentsPayload(t *testing.T, entries []Object) string {
t.Helper()
return mustJSON(t, map[string]any{
"type": "dir",
"sha": "tree-sha",
"entries": entries,
})
}
func newTreePayload(t *testing.T, sha string, trees []TreeObjResp) string {
t.Helper()
return mustJSON(t, map[string]any{
"sha": sha,
"truncated": false,
"tree": trees,
})
}
func newCommitGraphQLPayload(t *testing.T, histories map[string][]string) string {
t.Helper()
commit := make(map[string]any, len(histories))
for alias, dates := range histories {
nodes := make([]map[string]string, 0, len(dates))
for _, date := range dates {
nodes = append(nodes, map[string]string{"committedDate": date})
}
commit[alias] = map[string]any{"nodes": nodes}
}
return mustJSON(t, map[string]any{
"data": map[string]any{
"repository": map[string]any{"commit": commit},
},
})
}
func newSequentialEntries(count int) []Object {
entries := make([]Object, 0, count)
for i := range count {
name := fmt.Sprintf("%03d.md", i)
entries = append(entries, Object{Name: name, Path: "docs/" + name, Type: "file", Size: 1})
}
return entries
}
func mustObject(t *testing.T, obj model.Obj) *model.Object {
t.Helper()
raw, ok := model.UnwrapObjName(obj).(*model.Object)
if !ok {
t.Fatalf("unexpected obj type %T", obj)
}
return raw
}
func TestListAppliesAccurateModifiedTimeInOneRequest(t *testing.T) {
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
entries := []Object{
{Name: "a.md", Path: "docs/a.md", Type: "file", Size: 1},
{Name: ".gitkeep", Path: "docs/.gitkeep", Type: "file"},
{Name: `quote " 文.md`, Path: `docs/quote " 文.md`, Type: "file", Size: 1},
{Name: "control.md", Path: "docs/control\x01.md", Type: "file", Size: 1},
}
graphqlCalls := 0
var query string
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, entries)), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
query = graphQLQueryFromRequest(t, r)
if got := r.Header.Get("Authorization"); got != "Bearer token" {
t.Fatalf("unexpected authorization header: %q", got)
}
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{
"p0": {stamp.Format(time.RFC3339)},
"p1": {},
"p2": {},
})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL request, got %d", graphqlCalls)
}
if strings.Count(query, "history(first: 1") != 3 ||
!strings.Contains(query, `object(expression: "main^{commit}")`) ||
!strings.Contains(query, `p0: history(first: 1, path: "docs/a.md")`) ||
!strings.Contains(query, `p1: history(first: 1, path: "docs/quote \" 文.md")`) ||
!strings.Contains(query, `p2: history(first: 1, path: "docs/control\u0001.md")`) {
t.Fatalf("query should peel the ref and contain all listed paths once:\n%s", query)
}
if len(objs) != 3 {
t.Fatalf("expected three objects after .gitkeep filtering, got %d", len(objs))
}
if first := mustObject(t, objs[0]); !first.ModTime().Equal(stamp) || !first.CreateTime().Equal(githubZeroTime) {
t.Fatalf("unexpected first timestamps: mod=%v create=%v", first.ModTime(), first.CreateTime())
}
if second := mustObject(t, objs[1]); !second.ModTime().Equal(githubZeroTime) || !second.CreateTime().Equal(githubZeroTime) {
t.Fatalf("unmatched entry should retain legacy timestamps: mod=%v create=%v", second.ModTime(), second.CreateTime())
}
}
func TestListSkipsAccurateModifiedTime(t *testing.T) {
tests := []struct {
name string
enabled bool
token string
entries int
}{
{name: "disabled", enabled: false, token: "token", entries: 1},
{name: "missing token", enabled: true, token: "", entries: 1},
{name: "over entry limit", enabled: true, token: "token", entries: 201},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
graphqlCalls := 0
payload := newContentsPayload(t, newSequentialEntries(tc.entries))
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, payload), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, nil)), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), tc.token, tc.enabled)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 0 {
t.Fatalf("expected zero GraphQL requests, got %d", graphqlCalls)
}
for _, obj := range objs {
raw := mustObject(t, obj)
if !raw.ModTime().Equal(githubZeroTime) || !raw.CreateTime().Equal(githubZeroTime) {
t.Fatalf("legacy timestamps should be preserved: mod=%v create=%v", raw.ModTime(), raw.CreateTime())
}
}
})
}
}
func TestListFallsBackWhenGraphQLFails(t *testing.T) {
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, newSequentialEntries(1))), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, `{"errors":[{"message":"rate limited"}]}`), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("GraphQL failure should be best-effort: %v", err)
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL request, got %d", graphqlCalls)
}
obj := mustObject(t, objs[0])
if !obj.ModTime().Equal(githubZeroTime) || !obj.CreateTime().Equal(githubZeroTime) {
t.Fatalf("failed GraphQL request should retain legacy timestamps: mod=%v create=%v", obj.ModTime(), obj.CreateTime())
}
}
func TestListUsesOneGraphQLRequestAtEntryLimit(t *testing.T) {
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
graphqlCalls := 0
var query string
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, newSequentialEntries(200))), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
query = graphQLQueryFromRequest(t, r)
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{
"p199": {stamp.Format(time.RFC3339)},
})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 1 || strings.Count(query, "history(first: 1") != 200 {
t.Fatalf("200 entries should share one request: calls=%d histories=%d", graphqlCalls, strings.Count(query, "history(first: 1"))
}
if !strings.Contains(query, `p199: history(first: 1, path: "docs/199.md")`) {
t.Fatalf("query missing final entry:\n%s", query)
}
if len(objs) != 200 || !mustObject(t, objs[199]).ModTime().Equal(stamp) {
t.Fatalf("unexpected final object timestamp")
}
}
func TestListKeepsTreeFallbackOnLegacyPath(t *testing.T) {
entries := make([]Object, 0, 1000)
for i := range 1000 {
name := fmt.Sprintf("dir-%d", i)
entries = append(entries, Object{Name: name, Path: "docs/" + name, Type: "dir"})
}
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, entries)), nil
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/git/trees/"):
return newJSONResponse(http.StatusOK, newTreePayload(t, "tree-sha", []TreeObjResp{{TreeObjReq: TreeObjReq{Path: "child.md", Mode: "100644", Type: "blob", Sha: "blob-sha"}, Size: 1, URL: "https://example.invalid/blob"}})), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, nil)), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 1 {
t.Fatalf("unexpected tree fallback result length: %d", len(objs))
}
first := mustObject(t, objs[0])
if first.GetPath() != "/child.md" {
t.Fatalf("unexpected tree fallback path: %s", first.GetPath())
}
if !first.ModTime().Equal(githubZeroTime) || !first.CreateTime().Equal(githubZeroTime) {
t.Fatalf("tree fallback should preserve legacy timestamps: mod=%v create=%v", first.ModTime(), first.CreateTime())
}
if graphqlCalls != 0 {
t.Fatalf("tree fallback should skip GraphQL, got %d calls", graphqlCalls)
}
}
func TestOpListCacheHitDoesNotRepeatGraphQL(t *testing.T) {
op.Cache.ClearAll()
defer op.Cache.ClearAll()
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, []Object{{Name: "a.md", Path: "a.md", Type: "file", Size: 1}})), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{"p0": {stamp.Format(time.RFC3339)}})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
first, err := op.List(context.Background(), drv, "/", model.ListArgs{})
if err != nil {
t.Fatalf("unexpected first list error: %v", err)
}
second, err := op.List(context.Background(), drv, "/", model.ListArgs{})
if err != nil {
t.Fatalf("unexpected second list error: %v", err)
}
if len(first) != 1 || len(second) != 1 {
t.Fatalf("unexpected cached results: first=%d second=%d", len(first), len(second))
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL call across cached lists, got %d", graphqlCalls)
}
if !mustObject(t, first[0]).ModTime().Equal(stamp) {
t.Fatalf("expected first list to include backfilled modified time, got %v", mustObject(t, first[0]).ModTime())
}
if !mustObject(t, second[0]).ModTime().Equal(stamp) {
t.Fatalf("expected cached list to retain modified time, got %v", mustObject(t, second[0]).ModTime())
}
}