gitforest 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +24 -4
- package/src/components/onboarding/DirectoriesStep.tsx +19 -19
- package/src/github/auth.ts +3 -3
- package/src/utils/debug.ts +4 -4
- package/.bunignore +0 -7
- package/.github/workflows/ci.yml +0 -73
- package/CLAUDE.md +0 -111
- package/CONTRIBUTING.md +0 -145
- package/bun.lock +0 -267
- package/bunfig.toml +0 -15
- package/cli +0 -0
- package/docs/ai/IMPROVEMENT_PLAN.md +0 -341
- package/docs/ai/VERIFICATION_REPORT.md +0 -87
- package/docs/ai/architecture.md +0 -169
- package/docs/ai/checks/check-2025-12-02-tests.md +0 -40
- package/docs/ai/checks/check-2025-12-02.md +0 -55
- package/docs/ai/checks/test-verification-report.md +0 -85
- package/docs/ai/implementation-guide.md +0 -776
- package/docs/ai/research/gitty-codebase-analysis.md +0 -221
- package/docs/ai/tickets/GENERAL-sitrep.md +0 -30
- package/docs/ai/tickets/TASK-database-tests-sitrep.md +0 -25
- package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +0 -28
- package/docs/ai/tickets/TASK-detail-modal-sitrep.md +0 -28
- package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +0 -24
- package/docs/ai/tickets/TASK-github-service-sitrep.md +0 -32
- package/docs/ai/tickets/TASK-github-token-sitrep.md +0 -51
- package/docs/ai/tickets/TASK-hascommits-sitrep.md +0 -35
- package/docs/ai/tickets/TASK-keybindings-sitrep.md +0 -26
- package/docs/ai/tickets/TASK-layout-sitrep.md +0 -25
- package/docs/ai/tickets/TASK-markdown-sitrep.md +0 -28
- package/docs/ai/tickets/TASK-project-item-sitrep.md +0 -79
- package/docs/ai/tickets/TASK-sitrep.md +0 -28
- package/docs/ai/tickets/TASK-state-sitrep.md +0 -26
- package/docs/ai/tickets/TASK-types-sitrep.md +0 -25
- package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +0 -26
- package/docs/ai/tickets/TKT-001-sitrep.md +0 -24
- package/docs/ai/tickets/TKT-002-sitrep.md +0 -25
- package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +0 -46
- package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +0 -135
- package/docs/ai/tickets/TKT-003-sitrep.md +0 -26
- package/docs/ai/tickets/TKT-004-sitrep.md +0 -27
- package/docs/ai/tickets/TKT-005-sitrep.md +0 -25
- package/docs/ai/tickets/TKT-006-sitrep.md +0 -26
- package/docs/ai/tickets/TKT-007-sitrep.md +0 -30
- package/docs/ai/tickets/TKT-008-sitrep.md +0 -32
- package/docs/ai/tickets/TKT-009-sitrep.md +0 -27
- package/docs/ai/tickets/TKT-010-sitrep.md +0 -27
- package/docs/ai/tickets/TKT-011-sitrep.md +0 -26
- package/docs/ai/tickets/TKT-012-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +0 -28
- package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +0 -24
- package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +0 -30
- package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +0 -28
- package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +0 -30
- package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +0 -95
- package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +0 -61
- package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +0 -30
- package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +0 -28
- package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +0 -24
- package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +0 -24
- package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +0 -24
- package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +0 -32
- package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +0 -30
- package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +0 -75
- package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +0 -32
- package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +0 -64
- package/docs/ai/tkt-001-fix-database-error.md +0 -217
- package/docs/ai/ui-enhancement-plan.md +0 -562
- package/test/integration/app.isolated.tsx +0 -240
- package/test/integration/cli-commands.test.ts +0 -287
- package/test/integration/cli-validation.test.ts +0 -264
- package/test/integration/git-operations.test.ts +0 -218
- package/test/integration/scanner.test.ts +0 -228
- package/test/preload.ts +0 -18
- package/test/unit/cli/commands.test.ts +0 -13
- package/test/unit/cli/formatters.test.ts +0 -1116
- package/test/unit/cli/github-commands.test.ts +0 -12
- package/test/unit/components/CloneDialog.test.tsx +0 -240
- package/test/unit/components/ColumnHeader.test.tsx +0 -128
- package/test/unit/components/CommandPalette.test.tsx +0 -355
- package/test/unit/components/ConfirmDialog.test.tsx +0 -111
- package/test/unit/components/ErrorBoundary.test.tsx +0 -139
- package/test/unit/components/FilterBar.test.tsx +0 -43
- package/test/unit/components/FilterOptionsOverlay.test.tsx +0 -197
- package/test/unit/components/HelpOverlay.test.tsx +0 -90
- package/test/unit/components/Layout.test.tsx +0 -328
- package/test/unit/components/MarkdownRenderer.test.tsx +0 -45
- package/test/unit/components/ProgressBar.test.tsx +0 -138
- package/test/unit/components/ProjectItem.test.tsx +0 -182
- package/test/unit/components/ProjectList.test.tsx +0 -311
- package/test/unit/components/RepoDetailModal.test.tsx +0 -445
- package/test/unit/components/StatusBar.test.tsx +0 -112
- package/test/unit/components/UnifiedProjectItem.test.tsx +0 -618
- package/test/unit/components/ViewModeIndicator.test.tsx +0 -137
- package/test/unit/components/test-utils.tsx +0 -63
- package/test/unit/config/loader.test.ts +0 -692
- package/test/unit/db/database.test.ts +0 -978
- package/test/unit/db/index.test.ts +0 -314
- package/test/unit/fixtures/setup.ts +0 -186
- package/test/unit/git/commands-untested.test.ts +0 -205
- package/test/unit/git/commands.test.ts +0 -269
- package/test/unit/git/operations.test.ts +0 -322
- package/test/unit/git/status.test.ts +0 -219
- package/test/unit/github/auth.test.ts +0 -317
- package/test/unit/github/cache.test.ts +0 -1028
- package/test/unit/github/cli.test.ts +0 -135
- package/test/unit/github/unified.test.ts +0 -1201
- package/test/unit/graceful-shutdown.test.ts +0 -83
- package/test/unit/hooks/useBackgroundFetch.test.tsx +0 -239
- package/test/unit/hooks/useConfirmDialogActions.test.tsx +0 -81
- package/test/unit/hooks/useKeyBindings.isolated.ts +0 -715
- package/test/unit/hooks/useProjects.test.tsx +0 -186
- package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +0 -115
- package/test/unit/hooks/useUnifiedRepos.test.tsx +0 -177
- package/test/unit/mocks/config.ts +0 -109
- package/test/unit/mocks/git-service.ts +0 -274
- package/test/unit/mocks/github-service.ts +0 -250
- package/test/unit/mocks/index.ts +0 -72
- package/test/unit/mocks/project.ts +0 -148
- package/test/unit/mocks/state-mocks.ts +0 -187
- package/test/unit/mocks/unified.ts +0 -169
- package/test/unit/operations/batch.test.ts +0 -216
- package/test/unit/operations/commands.test.ts +0 -550
- package/test/unit/scanner/errors.test.ts +0 -297
- package/test/unit/scanner/index.test.ts +0 -1011
- package/test/unit/scanner/markers.test.ts +0 -150
- package/test/unit/scanner/submodules.test.ts +0 -99
- package/test/unit/services/git-errors.test.ts +0 -190
- package/test/unit/services/git.test.ts +0 -442
- package/test/unit/services/github-errors.test.ts +0 -293
- package/test/unit/services/github.test.ts +0 -200
- package/test/unit/state/actions.test.ts +0 -217
- package/test/unit/state/reducer.test.ts +0 -745
- package/test/unit/state/store.test.tsx +0 -711
- package/test/unit/types/commands.test.ts +0 -220
- package/test/unit/types/schema.test.ts +0 -179
- package/test/unit/utils/array.test.ts +0 -73
- package/test/unit/utils/debug.test.ts +0 -23
- package/test/unit/utils/errors.test.ts +0 -295
- package/test/unit/utils/markdown.test.ts +0 -163
- package/test/unit/utils/project-utils.test.ts +0 -756
- package/test/unit/utils/rate-limiter.test.ts +0 -256
- package/test/unit/utils/retry.test.ts +0 -165
- package/test/unit/utils/strip-ansi.ts +0 -13
- package/test/unit/utils/timeout.test.ts +0 -93
- package/tsconfig.json +0 -29
|
@@ -1,1028 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { fetchGitHubReposWithCache } from "../../../src/github/cache.ts";
|
|
3
|
-
import type { GitHubRepoInfo } from "../../../src/types/index.ts";
|
|
4
|
-
import type { GitHubService } from "../../../src/services/github.ts";
|
|
5
|
-
import type { DbInstance } from "../../../src/db/index.ts";
|
|
6
|
-
|
|
7
|
-
const sampleRepo = {
|
|
8
|
-
id: 1,
|
|
9
|
-
name: "demo",
|
|
10
|
-
fullName: "owner/demo",
|
|
11
|
-
description: null,
|
|
12
|
-
htmlUrl: "",
|
|
13
|
-
sshUrl: "",
|
|
14
|
-
cloneUrl: "",
|
|
15
|
-
isPrivate: false,
|
|
16
|
-
isArchived: false,
|
|
17
|
-
isFork: false,
|
|
18
|
-
createdAt: "",
|
|
19
|
-
updatedAt: "",
|
|
20
|
-
pushedAt: "",
|
|
21
|
-
size: 0,
|
|
22
|
-
stargazersCount: 0,
|
|
23
|
-
forksCount: 0,
|
|
24
|
-
openIssuesCount: 0,
|
|
25
|
-
watchersCount: 0,
|
|
26
|
-
topics: [] as string[],
|
|
27
|
-
license: null as { name: string } | null,
|
|
28
|
-
hasIssues: true,
|
|
29
|
-
hasWiki: true,
|
|
30
|
-
hasDiscussions: false,
|
|
31
|
-
language: null as string | null,
|
|
32
|
-
defaultBranch: "main",
|
|
33
|
-
owner: { login: "owner", type: "User" as const },
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const toInfo = (data: typeof sampleRepo): GitHubRepoInfo => ({
|
|
37
|
-
name: data.name,
|
|
38
|
-
fullName: data.fullName,
|
|
39
|
-
owner: data.owner.login,
|
|
40
|
-
description: data.description,
|
|
41
|
-
htmlUrl: data.htmlUrl,
|
|
42
|
-
sshUrl: data.sshUrl,
|
|
43
|
-
cloneUrl: data.cloneUrl,
|
|
44
|
-
isPrivate: data.isPrivate,
|
|
45
|
-
isArchived: data.isArchived,
|
|
46
|
-
isFork: data.isFork,
|
|
47
|
-
pushedAt: null,
|
|
48
|
-
updatedAt: null,
|
|
49
|
-
defaultBranch: data.defaultBranch,
|
|
50
|
-
language: data.language,
|
|
51
|
-
size: data.size,
|
|
52
|
-
stargazersCount: data.stargazersCount,
|
|
53
|
-
forksCount: data.forksCount,
|
|
54
|
-
openIssuesCount: data.openIssuesCount,
|
|
55
|
-
watchersCount: data.watchersCount,
|
|
56
|
-
topics: data.topics,
|
|
57
|
-
license: data.license?.name ?? null,
|
|
58
|
-
hasIssues: data.hasIssues,
|
|
59
|
-
hasWiki: data.hasWiki,
|
|
60
|
-
hasDiscussions: data.hasDiscussions,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const makeDbStub = (): DbInstance =>
|
|
64
|
-
({
|
|
65
|
-
delete: () => ({ run: async () => {} }),
|
|
66
|
-
insert: () => ({ values: async () => {} }),
|
|
67
|
-
select: () => ({ from: () => ({ all: async () => [] }) }),
|
|
68
|
-
} as unknown as DbInstance);
|
|
69
|
-
|
|
70
|
-
describe("fetchGitHubReposWithCache token fallback", () => {
|
|
71
|
-
const originalGithubToken = process.env.GITHUB_TOKEN;
|
|
72
|
-
const originalGhToken = process.env.GH_TOKEN;
|
|
73
|
-
|
|
74
|
-
beforeEach(() => {
|
|
75
|
-
delete process.env.GITHUB_TOKEN;
|
|
76
|
-
delete process.env.GH_TOKEN;
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
afterEach(() => {
|
|
80
|
-
if (originalGithubToken !== undefined) {
|
|
81
|
-
process.env.GITHUB_TOKEN = originalGithubToken;
|
|
82
|
-
} else {
|
|
83
|
-
delete process.env.GITHUB_TOKEN;
|
|
84
|
-
}
|
|
85
|
-
if (originalGhToken !== undefined) {
|
|
86
|
-
process.env.GH_TOKEN = originalGhToken;
|
|
87
|
-
} else {
|
|
88
|
-
delete process.env.GH_TOKEN;
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("uses gh CLI token when env vars are missing", async () => {
|
|
93
|
-
let tokenProviderCalled = false;
|
|
94
|
-
const tokenProvider = async () => {
|
|
95
|
-
tokenProviderCalled = true;
|
|
96
|
-
process.env.GITHUB_TOKEN = "gh-from-cli";
|
|
97
|
-
return process.env.GITHUB_TOKEN;
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const githubService: GitHubService = {
|
|
101
|
-
hasToken: () => !!process.env.GITHUB_TOKEN,
|
|
102
|
-
getToken: () => process.env.GITHUB_TOKEN ?? null,
|
|
103
|
-
// Methods below are unused in this test
|
|
104
|
-
getAuthenticatedUser: async () => {
|
|
105
|
-
throw new Error("not implemented");
|
|
106
|
-
},
|
|
107
|
-
getUserOrgs: async () => [],
|
|
108
|
-
getUserRepos: async () => [],
|
|
109
|
-
getOrgRepos: async () => [],
|
|
110
|
-
getAllRepos: async () => [sampleRepo],
|
|
111
|
-
getRepo: async () => {
|
|
112
|
-
throw new Error("not implemented");
|
|
113
|
-
},
|
|
114
|
-
searchRepos: async () => [],
|
|
115
|
-
createRepo: async () => ({ success: false }),
|
|
116
|
-
archiveRepo: async () => ({ success: false }),
|
|
117
|
-
unarchiveRepo: async () => ({ success: false }),
|
|
118
|
-
deleteRepo: async () => ({ success: false }),
|
|
119
|
-
cloneRepo: async () => ({ success: false, operation: "clone", projectPath: "" }),
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const result = await fetchGitHubReposWithCache(
|
|
123
|
-
{},
|
|
124
|
-
0,
|
|
125
|
-
{
|
|
126
|
-
githubService,
|
|
127
|
-
toGitHubRepoInfo: toInfo,
|
|
128
|
-
db: makeDbStub(),
|
|
129
|
-
tokenProvider,
|
|
130
|
-
}
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
expect(tokenProviderCalled).toBe(true);
|
|
134
|
-
expect(result.repos).toHaveLength(1);
|
|
135
|
-
expect(result.repos[0]?.name).toBe("demo");
|
|
136
|
-
expect(result.fromCache).toBe(false);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("does not call gh CLI when env token exists", async () => {
|
|
140
|
-
process.env.GITHUB_TOKEN = "env-token";
|
|
141
|
-
let tokenProviderCalled = false;
|
|
142
|
-
const tokenProvider = async () => {
|
|
143
|
-
tokenProviderCalled = true;
|
|
144
|
-
return "gh-from-cli";
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const githubService: GitHubService = {
|
|
148
|
-
hasToken: () => !!process.env.GITHUB_TOKEN,
|
|
149
|
-
getToken: () => process.env.GITHUB_TOKEN ?? null,
|
|
150
|
-
getAuthenticatedUser: async () => {
|
|
151
|
-
throw new Error("not implemented");
|
|
152
|
-
},
|
|
153
|
-
getUserOrgs: async () => [],
|
|
154
|
-
getUserRepos: async () => [],
|
|
155
|
-
getOrgRepos: async () => [],
|
|
156
|
-
getAllRepos: async () => [sampleRepo],
|
|
157
|
-
getRepo: async () => {
|
|
158
|
-
throw new Error("not implemented");
|
|
159
|
-
},
|
|
160
|
-
searchRepos: async () => [],
|
|
161
|
-
createRepo: async () => ({ success: false }),
|
|
162
|
-
archiveRepo: async () => ({ success: false }),
|
|
163
|
-
unarchiveRepo: async () => ({ success: false }),
|
|
164
|
-
deleteRepo: async () => ({ success: false }),
|
|
165
|
-
cloneRepo: async () => ({ success: false, operation: "clone", projectPath: "" }),
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const result = await fetchGitHubReposWithCache(
|
|
169
|
-
{},
|
|
170
|
-
0,
|
|
171
|
-
{
|
|
172
|
-
githubService,
|
|
173
|
-
toGitHubRepoInfo: toInfo,
|
|
174
|
-
db: makeDbStub(),
|
|
175
|
-
tokenProvider,
|
|
176
|
-
}
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
expect(tokenProviderCalled).toBe(false);
|
|
180
|
-
expect(result.repos).toHaveLength(1);
|
|
181
|
-
expect(result.repos[0]?.name).toBe("demo");
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Tests for GitHub cache module
|
|
187
|
-
*
|
|
188
|
-
* Uses dependency injection instead of mock.module() for better test isolation
|
|
189
|
-
*/
|
|
190
|
-
|
|
191
|
-
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
192
|
-
import { Database } from "bun:sqlite";
|
|
193
|
-
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
194
|
-
import * as schema from "../../../src/db/schema.ts";
|
|
195
|
-
import {
|
|
196
|
-
saveGitHubReposToCache,
|
|
197
|
-
loadGitHubReposFromCache,
|
|
198
|
-
fetchGitHubReposWithCache,
|
|
199
|
-
clearGitHubCache,
|
|
200
|
-
type CacheDeps
|
|
201
|
-
} from "../../../src/github/cache.ts";
|
|
202
|
-
import type { GitHubRepoInfo } from "../../../src/types/index.ts";
|
|
203
|
-
import type { DbInstance } from "../../../src/db/index.ts";
|
|
204
|
-
import type { GitHubService } from "../../../src/services/github.ts";
|
|
205
|
-
import { createMockGitHubRepoInfo } from "../mocks/unified.ts";
|
|
206
|
-
|
|
207
|
-
// Helper function to create multiple mock repos
|
|
208
|
-
function createMockRepos(count: number, prefix = "repo"): GitHubRepoInfo[] {
|
|
209
|
-
return Array.from({ length: count }, (_, i) =>
|
|
210
|
-
createMockGitHubRepoInfo({
|
|
211
|
-
name: `${prefix}-${i + 1}`,
|
|
212
|
-
fullName: `user/${prefix}-${i + 1}`,
|
|
213
|
-
})
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Create an in-memory database for testing
|
|
219
|
-
*/
|
|
220
|
-
function createTestDb(): { db: DbInstance; sqliteDb: Database } {
|
|
221
|
-
const sqliteDb = new Database(":memory:");
|
|
222
|
-
sqliteDb.run("PRAGMA foreign_keys = ON");
|
|
223
|
-
|
|
224
|
-
// Create github_repos table
|
|
225
|
-
sqliteDb.run(`
|
|
226
|
-
CREATE TABLE IF NOT EXISTS github_repos (
|
|
227
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
228
|
-
name TEXT NOT NULL,
|
|
229
|
-
full_name TEXT NOT NULL UNIQUE,
|
|
230
|
-
owner TEXT NOT NULL,
|
|
231
|
-
description TEXT,
|
|
232
|
-
html_url TEXT,
|
|
233
|
-
ssh_url TEXT,
|
|
234
|
-
clone_url TEXT,
|
|
235
|
-
is_private INTEGER,
|
|
236
|
-
is_archived INTEGER,
|
|
237
|
-
is_fork INTEGER,
|
|
238
|
-
pushed_at INTEGER,
|
|
239
|
-
updated_at INTEGER,
|
|
240
|
-
default_branch TEXT,
|
|
241
|
-
language TEXT,
|
|
242
|
-
size INTEGER,
|
|
243
|
-
stargazers_count INTEGER,
|
|
244
|
-
forks_count INTEGER,
|
|
245
|
-
open_issues_count INTEGER,
|
|
246
|
-
watchers_count INTEGER,
|
|
247
|
-
topics TEXT,
|
|
248
|
-
license TEXT,
|
|
249
|
-
has_issues INTEGER,
|
|
250
|
-
has_wiki INTEGER,
|
|
251
|
-
has_discussions INTEGER,
|
|
252
|
-
last_fetched INTEGER
|
|
253
|
-
);
|
|
254
|
-
`);
|
|
255
|
-
|
|
256
|
-
const db = drizzle(sqliteDb, { schema });
|
|
257
|
-
return { db, sqliteDb };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Create a mock GitHub service for testing
|
|
262
|
-
*/
|
|
263
|
-
function createMockGitHubService(overrides?: Partial<GitHubService>): GitHubService {
|
|
264
|
-
return {
|
|
265
|
-
hasToken: mock(() => true),
|
|
266
|
-
getToken: mock(() => "mock-token"),
|
|
267
|
-
getAuthenticatedUser: mock(() => Promise.resolve({ login: "testuser", id: 1, type: "User" as const })),
|
|
268
|
-
getUserOrgs: mock(() => Promise.resolve([])),
|
|
269
|
-
getUserRepos: mock(() => Promise.resolve([])),
|
|
270
|
-
getOrgRepos: mock(() => Promise.resolve([])),
|
|
271
|
-
getAllRepos: mock(() => Promise.resolve([])),
|
|
272
|
-
getRepo: mock(() => Promise.resolve({} as any)),
|
|
273
|
-
searchRepos: mock(() => Promise.resolve([])),
|
|
274
|
-
createRepo: mock(() => Promise.resolve({ success: true })),
|
|
275
|
-
archiveRepo: mock(() => Promise.resolve({ success: true })),
|
|
276
|
-
unarchiveRepo: mock(() => Promise.resolve({ success: true })),
|
|
277
|
-
deleteRepo: mock(() => Promise.resolve({ success: true })),
|
|
278
|
-
cloneRepo: mock(() => Promise.resolve({ success: true, projectPath: "", operation: "clone" as const })),
|
|
279
|
-
...overrides,
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Create a mock toGitHubRepoInfo function for testing
|
|
285
|
-
*/
|
|
286
|
-
function createMockToGitHubRepoInfo(): (data: any) => GitHubRepoInfo {
|
|
287
|
-
return (data: any) => ({
|
|
288
|
-
name: data.name,
|
|
289
|
-
fullName: data.fullName,
|
|
290
|
-
owner: data.owner?.login || data.owner,
|
|
291
|
-
description: data.description,
|
|
292
|
-
htmlUrl: data.html_url || data.htmlUrl || "",
|
|
293
|
-
sshUrl: data.ssh_url || data.sshUrl || "",
|
|
294
|
-
cloneUrl: data.clone_url || data.cloneUrl || "",
|
|
295
|
-
isPrivate: data.is_private !== undefined ? Boolean(data.is_private) : Boolean(data.isPrivate),
|
|
296
|
-
isArchived: data.is_archived !== undefined ? Boolean(data.is_archived) : Boolean(data.isArchived),
|
|
297
|
-
isFork: data.is_fork !== undefined ? Boolean(data.is_fork) : Boolean(data.isFork),
|
|
298
|
-
pushedAt: data.pushed_at ? new Date(data.pushed_at) : data.pushedAt ? new Date(data.pushedAt) : null,
|
|
299
|
-
updatedAt: data.updated_at ? new Date(data.updated_at) : data.updatedAt ? new Date(data.updatedAt) : null,
|
|
300
|
-
defaultBranch: data.default_branch || data.defaultBranch || "main",
|
|
301
|
-
language: data.language,
|
|
302
|
-
size: data.size || 0,
|
|
303
|
-
stargazersCount: data.stargazers_count || data.stargazersCount || 0,
|
|
304
|
-
forksCount: data.forks_count || data.forksCount || 0,
|
|
305
|
-
openIssuesCount: data.open_issues_count || data.openIssuesCount || 0,
|
|
306
|
-
watchersCount: data.watchers_count || data.watchersCount || 0,
|
|
307
|
-
topics: data.topics || [],
|
|
308
|
-
license: data.license?.name || data.license || null,
|
|
309
|
-
hasIssues: data.has_issues !== undefined ? Boolean(data.has_issues) : Boolean(data.hasIssues),
|
|
310
|
-
hasWiki: data.has_wiki !== undefined ? Boolean(data.has_wiki) : Boolean(data.hasWiki),
|
|
311
|
-
hasDiscussions: data.has_discussions !== undefined ? Boolean(data.has_discussions) : Boolean(data.hasDiscussions),
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
describe("GitHub Cache", () => {
|
|
316
|
-
let sqliteDb: Database;
|
|
317
|
-
let db: DbInstance;
|
|
318
|
-
|
|
319
|
-
beforeEach(() => {
|
|
320
|
-
const testDb = createTestDb();
|
|
321
|
-
db = testDb.db;
|
|
322
|
-
sqliteDb = testDb.sqliteDb;
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
afterEach(() => {
|
|
326
|
-
sqliteDb.close();
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
describe("saveGitHubReposToCache", () => {
|
|
330
|
-
test("saves repos to cache successfully", async () => {
|
|
331
|
-
const repos = [
|
|
332
|
-
createMockGitHubRepoInfo({
|
|
333
|
-
name: "repo1",
|
|
334
|
-
fullName: "user/repo1",
|
|
335
|
-
topics: ["typescript", "react"]
|
|
336
|
-
}),
|
|
337
|
-
createMockGitHubRepoInfo({
|
|
338
|
-
name: "repo2",
|
|
339
|
-
fullName: "user/repo2",
|
|
340
|
-
topics: ["javascript", "node"]
|
|
341
|
-
}),
|
|
342
|
-
];
|
|
343
|
-
|
|
344
|
-
await saveGitHubReposToCache(repos, { db });
|
|
345
|
-
|
|
346
|
-
const cached = db.select().from(schema.githubRepos).all();
|
|
347
|
-
expect(cached).toHaveLength(2);
|
|
348
|
-
expect(cached[0]?.name).toBe("repo1");
|
|
349
|
-
expect(cached[0]?.fullName).toBe("user/repo1");
|
|
350
|
-
expect(cached[0]?.topics).toBe(JSON.stringify(["typescript", "react"]));
|
|
351
|
-
expect(cached[1]?.name).toBe("repo2");
|
|
352
|
-
expect(cached[1]?.fullName).toBe("user/repo2");
|
|
353
|
-
expect(cached[1]?.topics).toBe(JSON.stringify(["javascript", "node"]));
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
test("clears existing cache before saving", async () => {
|
|
357
|
-
// Insert initial data
|
|
358
|
-
db.insert(schema.githubRepos).values({
|
|
359
|
-
name: "old-repo",
|
|
360
|
-
fullName: "user/old-repo",
|
|
361
|
-
owner: "user",
|
|
362
|
-
lastFetched: new Date(Date.now() - 86400000), // 1 day ago
|
|
363
|
-
}).run();
|
|
364
|
-
|
|
365
|
-
expect(db.select().from(schema.githubRepos).all()).toHaveLength(1);
|
|
366
|
-
|
|
367
|
-
// Save new repos
|
|
368
|
-
const repos = [createMockGitHubRepoInfo({ name: "new-repo" })];
|
|
369
|
-
await saveGitHubReposToCache(repos, { db });
|
|
370
|
-
|
|
371
|
-
const cached = db.select().from(schema.githubRepos).all();
|
|
372
|
-
expect(cached).toHaveLength(1);
|
|
373
|
-
expect(cached[0]?.name).toBe("new-repo");
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
test("handles empty repos array", async () => {
|
|
377
|
-
await saveGitHubReposToCache([], { db });
|
|
378
|
-
|
|
379
|
-
const cached = db.select().from(schema.githubRepos).all();
|
|
380
|
-
expect(cached).toHaveLength(0);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
test("handles null/undefined fields gracefully", async () => {
|
|
384
|
-
const repos: GitHubRepoInfo[] = [{
|
|
385
|
-
name: "test-repo",
|
|
386
|
-
fullName: "user/test-repo",
|
|
387
|
-
owner: "user",
|
|
388
|
-
description: null,
|
|
389
|
-
htmlUrl: "",
|
|
390
|
-
sshUrl: "",
|
|
391
|
-
cloneUrl: "",
|
|
392
|
-
isPrivate: false,
|
|
393
|
-
isArchived: false,
|
|
394
|
-
isFork: false,
|
|
395
|
-
pushedAt: null,
|
|
396
|
-
updatedAt: null,
|
|
397
|
-
defaultBranch: "main",
|
|
398
|
-
language: null,
|
|
399
|
-
size: 0,
|
|
400
|
-
stargazersCount: 0,
|
|
401
|
-
forksCount: 0,
|
|
402
|
-
openIssuesCount: 0,
|
|
403
|
-
watchersCount: 0,
|
|
404
|
-
topics: undefined,
|
|
405
|
-
license: null,
|
|
406
|
-
hasIssues: false,
|
|
407
|
-
hasWiki: false,
|
|
408
|
-
hasDiscussions: false,
|
|
409
|
-
}];
|
|
410
|
-
|
|
411
|
-
await saveGitHubReposToCache(repos, { db });
|
|
412
|
-
|
|
413
|
-
const cached = db.select().from(schema.githubRepos).all();
|
|
414
|
-
expect(cached).toHaveLength(1);
|
|
415
|
-
expect(cached[0]?.topics).toBeNull();
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
test("handles database errors gracefully", async () => {
|
|
419
|
-
const repos = [createMockGitHubRepoInfo()];
|
|
420
|
-
|
|
421
|
-
// Should complete without throwing
|
|
422
|
-
await expect(saveGitHubReposToCache(repos, { db })).resolves.toBeUndefined();
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
describe("loadGitHubReposFromCache", () => {
|
|
427
|
-
test("loads repos from cache successfully", async () => {
|
|
428
|
-
// Insert test data
|
|
429
|
-
const now = new Date();
|
|
430
|
-
db.insert(schema.githubRepos).values([
|
|
431
|
-
{
|
|
432
|
-
name: "cached-repo1",
|
|
433
|
-
fullName: "user/cached-repo1",
|
|
434
|
-
owner: "user",
|
|
435
|
-
description: "Test repo 1",
|
|
436
|
-
htmlUrl: "https://github.com/user/cached-repo1",
|
|
437
|
-
sshUrl: "git@github.com:user/cached-repo1.git",
|
|
438
|
-
cloneUrl: "https://github.com/user/cached-repo1.git",
|
|
439
|
-
isPrivate: false,
|
|
440
|
-
isArchived: false,
|
|
441
|
-
isFork: false,
|
|
442
|
-
pushedAt: now,
|
|
443
|
-
updatedAt: now,
|
|
444
|
-
defaultBranch: "main",
|
|
445
|
-
language: "TypeScript",
|
|
446
|
-
size: 1024,
|
|
447
|
-
stargazersCount: 100,
|
|
448
|
-
forksCount: 20,
|
|
449
|
-
openIssuesCount: 5,
|
|
450
|
-
watchersCount: 50,
|
|
451
|
-
topics: JSON.stringify(["typescript", "react"]),
|
|
452
|
-
license: "MIT",
|
|
453
|
-
hasIssues: true,
|
|
454
|
-
hasWiki: true,
|
|
455
|
-
hasDiscussions: false,
|
|
456
|
-
lastFetched: now,
|
|
457
|
-
},
|
|
458
|
-
{
|
|
459
|
-
name: "cached-repo2",
|
|
460
|
-
fullName: "user/cached-repo2",
|
|
461
|
-
owner: "user",
|
|
462
|
-
description: "Test repo 2",
|
|
463
|
-
lastFetched: now,
|
|
464
|
-
}
|
|
465
|
-
]).run();
|
|
466
|
-
|
|
467
|
-
const repos = await loadGitHubReposFromCache({ db });
|
|
468
|
-
|
|
469
|
-
expect(repos).toHaveLength(2);
|
|
470
|
-
expect(repos[0]?.name).toBe("cached-repo1");
|
|
471
|
-
expect(repos[0]?.fullName).toBe("user/cached-repo1");
|
|
472
|
-
expect(repos[0]?.topics).toEqual(["typescript", "react"]);
|
|
473
|
-
expect(repos[1]?.name).toBe("cached-repo2");
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
test("handles empty cache", async () => {
|
|
477
|
-
const repos = await loadGitHubReposFromCache({ db });
|
|
478
|
-
expect(repos).toEqual([]);
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
test("JSON parsing of topics field", async () => {
|
|
482
|
-
db.insert(schema.githubRepos).values({
|
|
483
|
-
name: "topic-repo",
|
|
484
|
-
fullName: "user/topic-repo",
|
|
485
|
-
owner: "user",
|
|
486
|
-
topics: JSON.stringify(["javascript", "node", "express"]),
|
|
487
|
-
lastFetched: new Date(),
|
|
488
|
-
}).run();
|
|
489
|
-
|
|
490
|
-
const repos = await loadGitHubReposFromCache({ db });
|
|
491
|
-
expect(repos[0]?.topics).toEqual(["javascript", "node", "express"]);
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
test("handles invalid JSON in topics field", async () => {
|
|
495
|
-
db.insert(schema.githubRepos).values({
|
|
496
|
-
name: "invalid-topic-repo",
|
|
497
|
-
fullName: "user/invalid-topic-repo",
|
|
498
|
-
owner: "user",
|
|
499
|
-
topics: "invalid-json",
|
|
500
|
-
lastFetched: new Date(),
|
|
501
|
-
}).run();
|
|
502
|
-
|
|
503
|
-
const repos = await loadGitHubReposFromCache({ db });
|
|
504
|
-
expect(repos[0]?.topics).toEqual([]);
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
test("handles null/undefined database fields", async () => {
|
|
508
|
-
db.insert(schema.githubRepos).values({
|
|
509
|
-
name: "null-fields-repo",
|
|
510
|
-
fullName: "user/null-fields-repo",
|
|
511
|
-
owner: "user",
|
|
512
|
-
htmlUrl: null,
|
|
513
|
-
sshUrl: undefined,
|
|
514
|
-
isPrivate: null,
|
|
515
|
-
isArchived: null,
|
|
516
|
-
isFork: null,
|
|
517
|
-
defaultBranch: null,
|
|
518
|
-
size: null,
|
|
519
|
-
stargazersCount: null,
|
|
520
|
-
forksCount: null,
|
|
521
|
-
openIssuesCount: null,
|
|
522
|
-
watchersCount: null,
|
|
523
|
-
topics: null,
|
|
524
|
-
license: null,
|
|
525
|
-
hasIssues: null,
|
|
526
|
-
hasWiki: null,
|
|
527
|
-
hasDiscussions: null,
|
|
528
|
-
lastFetched: new Date(),
|
|
529
|
-
}).run();
|
|
530
|
-
|
|
531
|
-
const repos = await loadGitHubReposFromCache({ db });
|
|
532
|
-
const repo = repos[0];
|
|
533
|
-
|
|
534
|
-
expect(repo?.htmlUrl).toBe("");
|
|
535
|
-
expect(repo?.sshUrl).toBe("");
|
|
536
|
-
expect(repo?.isPrivate).toBe(false);
|
|
537
|
-
expect(repo?.isArchived).toBe(false);
|
|
538
|
-
expect(repo?.isFork).toBe(false);
|
|
539
|
-
expect(repo?.defaultBranch).toBe("main");
|
|
540
|
-
expect(repo?.size).toBe(0);
|
|
541
|
-
expect(repo?.stargazersCount).toBe(0);
|
|
542
|
-
expect(repo?.forksCount).toBe(0);
|
|
543
|
-
expect(repo?.openIssuesCount).toBe(0);
|
|
544
|
-
expect(repo?.watchersCount).toBe(0);
|
|
545
|
-
expect(repo?.topics).toEqual([]);
|
|
546
|
-
expect(repo?.license).toBeNull();
|
|
547
|
-
expect(repo?.hasIssues).toBe(false);
|
|
548
|
-
expect(repo?.hasWiki).toBe(false);
|
|
549
|
-
expect(repo?.hasDiscussions).toBe(false);
|
|
550
|
-
});
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
describe("fetchGitHubReposWithCache", () => {
|
|
554
|
-
test("returns cached data when cache is fresh", async () => {
|
|
555
|
-
const now = new Date();
|
|
556
|
-
const cachedRepo = {
|
|
557
|
-
name: "fresh-cache-repo",
|
|
558
|
-
fullName: "user/fresh-cache-repo",
|
|
559
|
-
owner: "user",
|
|
560
|
-
lastFetched: now,
|
|
561
|
-
updatedAt: now,
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
db.insert(schema.githubRepos).values(cachedRepo).run();
|
|
565
|
-
|
|
566
|
-
const mockService = createMockGitHubService();
|
|
567
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
568
|
-
db,
|
|
569
|
-
githubService: mockService,
|
|
570
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
expect(result.repos).toHaveLength(1);
|
|
574
|
-
expect(result.fromCache).toBe(true);
|
|
575
|
-
expect(result.repos[0]?.name).toBe("fresh-cache-repo");
|
|
576
|
-
expect(mockService.getAllRepos).not.toHaveBeenCalled();
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
test("fetches fresh data when cache is stale", async () => {
|
|
580
|
-
// Insert stale cache data (10 minutes ago)
|
|
581
|
-
const oldDate = new Date(Date.now() - 600000);
|
|
582
|
-
db.insert(schema.githubRepos).values({
|
|
583
|
-
name: "stale-cache-repo",
|
|
584
|
-
fullName: "user/stale-cache-repo",
|
|
585
|
-
owner: "user",
|
|
586
|
-
lastFetched: oldDate,
|
|
587
|
-
updatedAt: oldDate,
|
|
588
|
-
}).run();
|
|
589
|
-
|
|
590
|
-
// Mock getAllRepos to return empty array (simulating no new repos)
|
|
591
|
-
const mockService = createMockGitHubService({
|
|
592
|
-
getAllRepos: mock(() => Promise.resolve([])),
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
596
|
-
db,
|
|
597
|
-
githubService: mockService,
|
|
598
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
// Since cache is stale, it should fetch fresh data
|
|
602
|
-
expect(result.fromCache).toBe(false);
|
|
603
|
-
expect(mockService.getAllRepos).toHaveBeenCalled();
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
test("returns error when no token", async () => {
|
|
607
|
-
const mockService = createMockGitHubService({
|
|
608
|
-
hasToken: mock(() => false),
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
612
|
-
db,
|
|
613
|
-
githubService: mockService,
|
|
614
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
expect(result.repos).toEqual([]);
|
|
618
|
-
expect(result.fromCache).toBe(false);
|
|
619
|
-
expect(result.error).toBe("GITHUB_TOKEN not set");
|
|
620
|
-
expect(mockService.getAllRepos).not.toHaveBeenCalled();
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
test("fallback to stale cache on fetch error", async () => {
|
|
624
|
-
// Insert stale cache data
|
|
625
|
-
const staleDate = new Date(Date.now() - 600000); // 10 minutes ago
|
|
626
|
-
db.insert(schema.githubRepos).values({
|
|
627
|
-
name: "stale-cache-repo",
|
|
628
|
-
fullName: "user/stale-cache-repo",
|
|
629
|
-
owner: "user",
|
|
630
|
-
lastFetched: staleDate,
|
|
631
|
-
updatedAt: staleDate,
|
|
632
|
-
}).run();
|
|
633
|
-
|
|
634
|
-
// Mock fetch error
|
|
635
|
-
const mockService = createMockGitHubService({
|
|
636
|
-
getAllRepos: mock(() => Promise.reject(new Error("API Error"))),
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
640
|
-
db,
|
|
641
|
-
githubService: mockService,
|
|
642
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
expect(result.repos).toHaveLength(1);
|
|
646
|
-
expect(result.fromCache).toBe(true);
|
|
647
|
-
expect(result.error).toBe("API Error");
|
|
648
|
-
expect(result.repos[0]?.name).toBe("stale-cache-repo");
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
test("returns empty array when fetch fails and no cache available", async () => {
|
|
652
|
-
const mockService = createMockGitHubService({
|
|
653
|
-
getAllRepos: mock(() => Promise.reject(new Error("Network Error"))),
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
657
|
-
db,
|
|
658
|
-
githubService: mockService,
|
|
659
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
expect(result.repos).toEqual([]);
|
|
663
|
-
expect(result.fromCache).toBe(false);
|
|
664
|
-
expect(result.error).toBe("Network Error");
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
test("respects custom cache TTL", async () => {
|
|
668
|
-
// Insert data that's 2 minutes old (120 seconds)
|
|
669
|
-
const twoMinutesAgo = new Date(Date.now() - 120000);
|
|
670
|
-
db.insert(schema.githubRepos).values({
|
|
671
|
-
name: "ttl-test-repo",
|
|
672
|
-
fullName: "user/ttl-test-repo",
|
|
673
|
-
owner: "user",
|
|
674
|
-
lastFetched: twoMinutesAgo,
|
|
675
|
-
updatedAt: twoMinutesAgo,
|
|
676
|
-
}).run();
|
|
677
|
-
|
|
678
|
-
const mockService = createMockGitHubService({
|
|
679
|
-
getAllRepos: mock(() => Promise.resolve([])),
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
// With TTL of 300 seconds (5 minutes), cache should be fresh
|
|
683
|
-
let result = await fetchGitHubReposWithCache({}, 300, {
|
|
684
|
-
db,
|
|
685
|
-
githubService: mockService,
|
|
686
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
687
|
-
});
|
|
688
|
-
expect(result.fromCache).toBe(true);
|
|
689
|
-
|
|
690
|
-
// Reset mock call count
|
|
691
|
-
(mockService.getAllRepos as any).mockClear();
|
|
692
|
-
|
|
693
|
-
// With TTL of 60 seconds (1 minute), cache should be stale
|
|
694
|
-
result = await fetchGitHubReposWithCache({}, 60, {
|
|
695
|
-
db,
|
|
696
|
-
githubService: mockService,
|
|
697
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
698
|
-
});
|
|
699
|
-
expect(result.fromCache).toBe(false);
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
test("passes options to getAllRepos", async () => {
|
|
703
|
-
const mockService = createMockGitHubService({
|
|
704
|
-
getAllRepos: mock(() => Promise.resolve([])),
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
const options = {
|
|
708
|
-
includeOrgs: true,
|
|
709
|
-
includeArchived: false,
|
|
710
|
-
includeForks: true,
|
|
711
|
-
};
|
|
712
|
-
|
|
713
|
-
await fetchGitHubReposWithCache(options, 300, {
|
|
714
|
-
db,
|
|
715
|
-
githubService: mockService,
|
|
716
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
expect(mockService.getAllRepos).toHaveBeenCalledWith(options);
|
|
720
|
-
});
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
describe("Cache freshness check", () => {
|
|
724
|
-
test("correctly identifies fresh cache", async () => {
|
|
725
|
-
// Insert fresh data (1 minute ago)
|
|
726
|
-
const oneMinuteAgo = new Date(Date.now() - 60000);
|
|
727
|
-
db.insert(schema.githubRepos).values({
|
|
728
|
-
name: "fresh-repo",
|
|
729
|
-
fullName: "user/fresh-repo",
|
|
730
|
-
owner: "user",
|
|
731
|
-
lastFetched: oneMinuteAgo,
|
|
732
|
-
updatedAt: oneMinuteAgo,
|
|
733
|
-
}).run();
|
|
734
|
-
|
|
735
|
-
const mockService = createMockGitHubService();
|
|
736
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
737
|
-
db,
|
|
738
|
-
githubService: mockService,
|
|
739
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
740
|
-
}); // 5 minute TTL
|
|
741
|
-
|
|
742
|
-
expect(result.fromCache).toBe(true);
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
test("correctly identifies stale cache", async () => {
|
|
746
|
-
// Insert stale data (10 minutes ago)
|
|
747
|
-
const tenMinutesAgo = new Date(Date.now() - 600000);
|
|
748
|
-
db.insert(schema.githubRepos).values({
|
|
749
|
-
name: "stale-repo",
|
|
750
|
-
fullName: "user/stale-repo",
|
|
751
|
-
owner: "user",
|
|
752
|
-
lastFetched: tenMinutesAgo,
|
|
753
|
-
updatedAt: tenMinutesAgo,
|
|
754
|
-
}).run();
|
|
755
|
-
|
|
756
|
-
const mockService = createMockGitHubService({
|
|
757
|
-
getAllRepos: mock(() => Promise.resolve([])),
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
761
|
-
db,
|
|
762
|
-
githubService: mockService,
|
|
763
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
764
|
-
}); // 5 minute TTL
|
|
765
|
-
|
|
766
|
-
expect(result.fromCache).toBe(false);
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
test("handles null updatedAt as stale", async () => {
|
|
770
|
-
// Insert data with null updatedAt
|
|
771
|
-
db.insert(schema.githubRepos).values({
|
|
772
|
-
name: "null-date-repo",
|
|
773
|
-
fullName: "user/null-date-repo",
|
|
774
|
-
owner: "user",
|
|
775
|
-
lastFetched: new Date(),
|
|
776
|
-
updatedAt: null,
|
|
777
|
-
}).run();
|
|
778
|
-
|
|
779
|
-
const mockService = createMockGitHubService({
|
|
780
|
-
getAllRepos: mock(() => Promise.resolve([])),
|
|
781
|
-
});
|
|
782
|
-
|
|
783
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
784
|
-
db,
|
|
785
|
-
githubService: mockService,
|
|
786
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
expect(result.fromCache).toBe(false);
|
|
790
|
-
});
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
describe("Additional fetchGitHubReposWithCache tests", () => {
|
|
794
|
-
test("returns cached data when fresh (within TTL)", async () => {
|
|
795
|
-
// Insert fresh cache data
|
|
796
|
-
const now = new Date();
|
|
797
|
-
db.insert(schema.githubRepos).values({
|
|
798
|
-
name: "fresh-cache-repo",
|
|
799
|
-
fullName: "user/fresh-cache-repo",
|
|
800
|
-
owner: "user",
|
|
801
|
-
lastFetched: now,
|
|
802
|
-
updatedAt: now,
|
|
803
|
-
}).run();
|
|
804
|
-
|
|
805
|
-
const mockService = createMockGitHubService();
|
|
806
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
807
|
-
db,
|
|
808
|
-
githubService: mockService,
|
|
809
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
expect(result.repos).toHaveLength(1);
|
|
813
|
-
expect(result.fromCache).toBe(true);
|
|
814
|
-
expect(result.repos[0]?.name).toBe("fresh-cache-repo");
|
|
815
|
-
expect(mockService.getAllRepos).not.toHaveBeenCalled();
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
test("fetches fresh data when TTL is 0", async () => {
|
|
819
|
-
// Insert cache data
|
|
820
|
-
db.insert(schema.githubRepos).values({
|
|
821
|
-
name: "cache-repo",
|
|
822
|
-
fullName: "user/cache-repo",
|
|
823
|
-
owner: "user",
|
|
824
|
-
lastFetched: new Date(),
|
|
825
|
-
updatedAt: new Date(),
|
|
826
|
-
}).run();
|
|
827
|
-
|
|
828
|
-
// Mock getAllRepos to return new repos
|
|
829
|
-
const newRepos = createMockRepos(1, "fresh-repo");
|
|
830
|
-
const mockService = createMockGitHubService({
|
|
831
|
-
getAllRepos: mock(() => Promise.resolve(newRepos as any)),
|
|
832
|
-
});
|
|
833
|
-
|
|
834
|
-
const result = await fetchGitHubReposWithCache({}, 0, {
|
|
835
|
-
db,
|
|
836
|
-
githubService: mockService,
|
|
837
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
expect(result.fromCache).toBe(false);
|
|
841
|
-
expect(result.repos).toHaveLength(1);
|
|
842
|
-
expect(result.repos[0]?.name).toBe("fresh-repo-1");
|
|
843
|
-
expect(mockService.getAllRepos).toHaveBeenCalled();
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
test("saves fetched data to cache", async () => {
|
|
847
|
-
// Mock getAllRepos to return repos
|
|
848
|
-
const newRepos = createMockRepos(3, "saved-repo");
|
|
849
|
-
const mockService = createMockGitHubService({
|
|
850
|
-
getAllRepos: mock(() => Promise.resolve(newRepos as any)),
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
854
|
-
db,
|
|
855
|
-
githubService: mockService,
|
|
856
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
expect(result.fromCache).toBe(false);
|
|
860
|
-
expect(result.repos).toHaveLength(3);
|
|
861
|
-
|
|
862
|
-
// Wait a bit for background save to complete
|
|
863
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
864
|
-
|
|
865
|
-
// Verify cache was updated
|
|
866
|
-
const cached = await loadGitHubReposFromCache({ db });
|
|
867
|
-
expect(cached).toHaveLength(3);
|
|
868
|
-
expect(cached[0]?.name).toBe("saved-repo-1");
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
test("returns error message when GitHub token not set", async () => {
|
|
872
|
-
const mockService = createMockGitHubService({
|
|
873
|
-
hasToken: mock(() => false),
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
877
|
-
db,
|
|
878
|
-
githubService: mockService,
|
|
879
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
expect(result.repos).toEqual([]);
|
|
883
|
-
expect(result.fromCache).toBe(false);
|
|
884
|
-
expect(result.error).toBe("GITHUB_TOKEN not set");
|
|
885
|
-
expect(mockService.getAllRepos).not.toHaveBeenCalled();
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
test("handles API errors gracefully", async () => {
|
|
889
|
-
// Mock API error
|
|
890
|
-
const mockService = createMockGitHubService({
|
|
891
|
-
getAllRepos: mock(() => Promise.reject(new Error("API Rate Limit Exceeded"))),
|
|
892
|
-
});
|
|
893
|
-
|
|
894
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
895
|
-
db,
|
|
896
|
-
githubService: mockService,
|
|
897
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
expect(result.repos).toEqual([]);
|
|
901
|
-
expect(result.fromCache).toBe(false);
|
|
902
|
-
expect(result.error).toBe("API Rate Limit Exceeded");
|
|
903
|
-
});
|
|
904
|
-
|
|
905
|
-
test("returns cached data on API failure if available", async () => {
|
|
906
|
-
// Insert stale cache data
|
|
907
|
-
const staleDate = new Date(Date.now() - 600000); // 10 minutes ago
|
|
908
|
-
db.insert(schema.githubRepos).values({
|
|
909
|
-
name: "stale-cache-repo",
|
|
910
|
-
fullName: "user/stale-cache-repo",
|
|
911
|
-
owner: "user",
|
|
912
|
-
lastFetched: staleDate,
|
|
913
|
-
updatedAt: staleDate,
|
|
914
|
-
}).run();
|
|
915
|
-
|
|
916
|
-
// Mock API error
|
|
917
|
-
const mockService = createMockGitHubService({
|
|
918
|
-
getAllRepos: mock(() => Promise.reject(new Error("Network Error"))),
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
const result = await fetchGitHubReposWithCache({}, 300, {
|
|
922
|
-
db,
|
|
923
|
-
githubService: mockService,
|
|
924
|
-
toGitHubRepoInfo: createMockToGitHubRepoInfo(),
|
|
925
|
-
});
|
|
926
|
-
|
|
927
|
-
expect(result.repos).toHaveLength(1);
|
|
928
|
-
expect(result.fromCache).toBe(true);
|
|
929
|
-
expect(result.error).toBe("Network Error");
|
|
930
|
-
expect(result.repos[0]?.name).toBe("stale-cache-repo");
|
|
931
|
-
});
|
|
932
|
-
});
|
|
933
|
-
|
|
934
|
-
describe("Additional loadGitHubReposFromCache tests", () => {
|
|
935
|
-
test("returns empty array when no cache", async () => {
|
|
936
|
-
const repos = await loadGitHubReposFromCache({ db });
|
|
937
|
-
expect(repos).toEqual([]);
|
|
938
|
-
});
|
|
939
|
-
|
|
940
|
-
test("returns cached repos when available", async () => {
|
|
941
|
-
const now = new Date();
|
|
942
|
-
db.insert(schema.githubRepos).values([
|
|
943
|
-
{
|
|
944
|
-
name: "repo1",
|
|
945
|
-
fullName: "user/repo1",
|
|
946
|
-
owner: "user",
|
|
947
|
-
lastFetched: now,
|
|
948
|
-
},
|
|
949
|
-
{
|
|
950
|
-
name: "repo2",
|
|
951
|
-
fullName: "user/repo2",
|
|
952
|
-
owner: "user",
|
|
953
|
-
lastFetched: now,
|
|
954
|
-
}
|
|
955
|
-
]).run();
|
|
956
|
-
|
|
957
|
-
const repos = await loadGitHubReposFromCache({ db });
|
|
958
|
-
|
|
959
|
-
expect(repos).toHaveLength(2);
|
|
960
|
-
expect(repos[0]?.name).toBe("repo1");
|
|
961
|
-
expect(repos[1]?.name).toBe("repo2");
|
|
962
|
-
});
|
|
963
|
-
|
|
964
|
-
test("handles corrupted cache data", async () => {
|
|
965
|
-
// Insert data with corrupted topics JSON
|
|
966
|
-
db.insert(schema.githubRepos).values({
|
|
967
|
-
name: "corrupted-repo",
|
|
968
|
-
fullName: "user/corrupted-repo",
|
|
969
|
-
owner: "user",
|
|
970
|
-
topics: "{invalid json",
|
|
971
|
-
lastFetched: new Date(),
|
|
972
|
-
}).run();
|
|
973
|
-
|
|
974
|
-
// Should not throw and return empty topics array
|
|
975
|
-
const repos = await loadGitHubReposFromCache({ db });
|
|
976
|
-
expect(repos).toHaveLength(1);
|
|
977
|
-
expect(repos[0]?.topics).toEqual([]);
|
|
978
|
-
});
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
describe("Additional saveGitHubReposToCache tests", () => {
|
|
982
|
-
test("saves repos to database", async () => {
|
|
983
|
-
const repos = [
|
|
984
|
-
createMockGitHubRepoInfo({ name: "test-repo-1" }),
|
|
985
|
-
createMockGitHubRepoInfo({ name: "test-repo-2" }),
|
|
986
|
-
];
|
|
987
|
-
|
|
988
|
-
await saveGitHubReposToCache(repos, { db });
|
|
989
|
-
|
|
990
|
-
const cached = db.select().from(schema.githubRepos).all();
|
|
991
|
-
expect(cached).toHaveLength(2);
|
|
992
|
-
expect(cached[0]?.name).toBe("test-repo-1");
|
|
993
|
-
expect(cached[1]?.name).toBe("test-repo-2");
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
test("updates existing cache entries", async () => {
|
|
997
|
-
// Insert initial data
|
|
998
|
-
db.insert(schema.githubRepos).values({
|
|
999
|
-
name: "old-repo",
|
|
1000
|
-
fullName: "user/old-repo",
|
|
1001
|
-
owner: "user",
|
|
1002
|
-
description: "Old description",
|
|
1003
|
-
lastFetched: new Date(Date.now() - 86400000), // 1 day ago
|
|
1004
|
-
}).run();
|
|
1005
|
-
|
|
1006
|
-
const newRepos = [
|
|
1007
|
-
createMockGitHubRepoInfo({
|
|
1008
|
-
name: "new-repo",
|
|
1009
|
-
description: "New description"
|
|
1010
|
-
}),
|
|
1011
|
-
];
|
|
1012
|
-
|
|
1013
|
-
await saveGitHubReposToCache(newRepos, { db });
|
|
1014
|
-
|
|
1015
|
-
const cached = db.select().from(schema.githubRepos).all();
|
|
1016
|
-
expect(cached).toHaveLength(1);
|
|
1017
|
-
expect(cached[0]?.name).toBe("new-repo");
|
|
1018
|
-
expect(cached[0]?.description).toBe("New description");
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
test("handles save failures gracefully", async () => {
|
|
1022
|
-
const repos = [createMockGitHubRepoInfo()];
|
|
1023
|
-
|
|
1024
|
-
// Should complete without throwing
|
|
1025
|
-
await expect(saveGitHubReposToCache(repos, { db })).resolves.toBeUndefined();
|
|
1026
|
-
});
|
|
1027
|
-
});
|
|
1028
|
-
});
|