gitforest 0.1.0 → 1.0.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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +24 -4
  3. package/src/github/auth.ts +3 -3
  4. package/src/utils/debug.ts +4 -4
  5. package/.bunignore +0 -7
  6. package/.github/workflows/ci.yml +0 -73
  7. package/CLAUDE.md +0 -111
  8. package/CONTRIBUTING.md +0 -145
  9. package/bun.lock +0 -267
  10. package/bunfig.toml +0 -15
  11. package/cli +0 -0
  12. package/docs/ai/IMPROVEMENT_PLAN.md +0 -341
  13. package/docs/ai/VERIFICATION_REPORT.md +0 -87
  14. package/docs/ai/architecture.md +0 -169
  15. package/docs/ai/checks/check-2025-12-02-tests.md +0 -40
  16. package/docs/ai/checks/check-2025-12-02.md +0 -55
  17. package/docs/ai/checks/test-verification-report.md +0 -85
  18. package/docs/ai/implementation-guide.md +0 -776
  19. package/docs/ai/research/gitty-codebase-analysis.md +0 -221
  20. package/docs/ai/tickets/GENERAL-sitrep.md +0 -30
  21. package/docs/ai/tickets/TASK-database-tests-sitrep.md +0 -25
  22. package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +0 -28
  23. package/docs/ai/tickets/TASK-detail-modal-sitrep.md +0 -28
  24. package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +0 -24
  25. package/docs/ai/tickets/TASK-github-service-sitrep.md +0 -32
  26. package/docs/ai/tickets/TASK-github-token-sitrep.md +0 -51
  27. package/docs/ai/tickets/TASK-hascommits-sitrep.md +0 -35
  28. package/docs/ai/tickets/TASK-keybindings-sitrep.md +0 -26
  29. package/docs/ai/tickets/TASK-layout-sitrep.md +0 -25
  30. package/docs/ai/tickets/TASK-markdown-sitrep.md +0 -28
  31. package/docs/ai/tickets/TASK-project-item-sitrep.md +0 -79
  32. package/docs/ai/tickets/TASK-sitrep.md +0 -28
  33. package/docs/ai/tickets/TASK-state-sitrep.md +0 -26
  34. package/docs/ai/tickets/TASK-types-sitrep.md +0 -25
  35. package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +0 -26
  36. package/docs/ai/tickets/TKT-001-sitrep.md +0 -24
  37. package/docs/ai/tickets/TKT-002-sitrep.md +0 -25
  38. package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +0 -46
  39. package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +0 -135
  40. package/docs/ai/tickets/TKT-003-sitrep.md +0 -26
  41. package/docs/ai/tickets/TKT-004-sitrep.md +0 -27
  42. package/docs/ai/tickets/TKT-005-sitrep.md +0 -25
  43. package/docs/ai/tickets/TKT-006-sitrep.md +0 -26
  44. package/docs/ai/tickets/TKT-007-sitrep.md +0 -30
  45. package/docs/ai/tickets/TKT-008-sitrep.md +0 -32
  46. package/docs/ai/tickets/TKT-009-sitrep.md +0 -27
  47. package/docs/ai/tickets/TKT-010-sitrep.md +0 -27
  48. package/docs/ai/tickets/TKT-011-sitrep.md +0 -26
  49. package/docs/ai/tickets/TKT-012-sitrep.md +0 -25
  50. package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +0 -28
  51. package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +0 -25
  52. package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +0 -25
  53. package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +0 -24
  54. package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +0 -29
  55. package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +0 -29
  56. package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +0 -26
  57. package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +0 -30
  58. package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +0 -28
  59. package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +0 -26
  60. package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +0 -25
  61. package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +0 -30
  62. package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +0 -25
  63. package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +0 -29
  64. package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +0 -95
  65. package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +0 -61
  66. package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +0 -30
  67. package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +0 -27
  68. package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +0 -25
  69. package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +0 -27
  70. package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +0 -25
  71. package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +0 -28
  72. package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +0 -25
  73. package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +0 -26
  74. package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +0 -25
  75. package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +0 -26
  76. package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +0 -24
  77. package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +0 -25
  78. package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +0 -29
  79. package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +0 -25
  80. package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +0 -24
  81. package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +0 -24
  82. package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +0 -27
  83. package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +0 -25
  84. package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +0 -25
  85. package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +0 -27
  86. package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +0 -32
  87. package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +0 -27
  88. package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +0 -30
  89. package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +0 -25
  90. package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +0 -29
  91. package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +0 -25
  92. package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +0 -27
  93. package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +0 -25
  94. package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +0 -26
  95. package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +0 -25
  96. package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +0 -27
  97. package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +0 -75
  98. package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +0 -29
  99. package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +0 -29
  100. package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +0 -25
  101. package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +0 -25
  102. package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +0 -32
  103. package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +0 -64
  104. package/docs/ai/tkt-001-fix-database-error.md +0 -217
  105. package/docs/ai/ui-enhancement-plan.md +0 -562
  106. package/test/integration/app.isolated.tsx +0 -240
  107. package/test/integration/cli-commands.test.ts +0 -287
  108. package/test/integration/cli-validation.test.ts +0 -264
  109. package/test/integration/git-operations.test.ts +0 -218
  110. package/test/integration/scanner.test.ts +0 -228
  111. package/test/preload.ts +0 -18
  112. package/test/unit/cli/commands.test.ts +0 -13
  113. package/test/unit/cli/formatters.test.ts +0 -1116
  114. package/test/unit/cli/github-commands.test.ts +0 -12
  115. package/test/unit/components/CloneDialog.test.tsx +0 -240
  116. package/test/unit/components/ColumnHeader.test.tsx +0 -128
  117. package/test/unit/components/CommandPalette.test.tsx +0 -355
  118. package/test/unit/components/ConfirmDialog.test.tsx +0 -111
  119. package/test/unit/components/ErrorBoundary.test.tsx +0 -139
  120. package/test/unit/components/FilterBar.test.tsx +0 -43
  121. package/test/unit/components/FilterOptionsOverlay.test.tsx +0 -197
  122. package/test/unit/components/HelpOverlay.test.tsx +0 -90
  123. package/test/unit/components/Layout.test.tsx +0 -328
  124. package/test/unit/components/MarkdownRenderer.test.tsx +0 -45
  125. package/test/unit/components/ProgressBar.test.tsx +0 -138
  126. package/test/unit/components/ProjectItem.test.tsx +0 -182
  127. package/test/unit/components/ProjectList.test.tsx +0 -311
  128. package/test/unit/components/RepoDetailModal.test.tsx +0 -445
  129. package/test/unit/components/StatusBar.test.tsx +0 -112
  130. package/test/unit/components/UnifiedProjectItem.test.tsx +0 -618
  131. package/test/unit/components/ViewModeIndicator.test.tsx +0 -137
  132. package/test/unit/components/test-utils.tsx +0 -63
  133. package/test/unit/config/loader.test.ts +0 -692
  134. package/test/unit/db/database.test.ts +0 -978
  135. package/test/unit/db/index.test.ts +0 -314
  136. package/test/unit/fixtures/setup.ts +0 -186
  137. package/test/unit/git/commands-untested.test.ts +0 -205
  138. package/test/unit/git/commands.test.ts +0 -269
  139. package/test/unit/git/operations.test.ts +0 -322
  140. package/test/unit/git/status.test.ts +0 -219
  141. package/test/unit/github/auth.test.ts +0 -317
  142. package/test/unit/github/cache.test.ts +0 -1028
  143. package/test/unit/github/cli.test.ts +0 -135
  144. package/test/unit/github/unified.test.ts +0 -1201
  145. package/test/unit/graceful-shutdown.test.ts +0 -83
  146. package/test/unit/hooks/useBackgroundFetch.test.tsx +0 -239
  147. package/test/unit/hooks/useConfirmDialogActions.test.tsx +0 -81
  148. package/test/unit/hooks/useKeyBindings.isolated.ts +0 -715
  149. package/test/unit/hooks/useProjects.test.tsx +0 -186
  150. package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +0 -115
  151. package/test/unit/hooks/useUnifiedRepos.test.tsx +0 -177
  152. package/test/unit/mocks/config.ts +0 -109
  153. package/test/unit/mocks/git-service.ts +0 -274
  154. package/test/unit/mocks/github-service.ts +0 -250
  155. package/test/unit/mocks/index.ts +0 -72
  156. package/test/unit/mocks/project.ts +0 -148
  157. package/test/unit/mocks/state-mocks.ts +0 -187
  158. package/test/unit/mocks/unified.ts +0 -169
  159. package/test/unit/operations/batch.test.ts +0 -216
  160. package/test/unit/operations/commands.test.ts +0 -550
  161. package/test/unit/scanner/errors.test.ts +0 -297
  162. package/test/unit/scanner/index.test.ts +0 -1011
  163. package/test/unit/scanner/markers.test.ts +0 -150
  164. package/test/unit/scanner/submodules.test.ts +0 -99
  165. package/test/unit/services/git-errors.test.ts +0 -190
  166. package/test/unit/services/git.test.ts +0 -442
  167. package/test/unit/services/github-errors.test.ts +0 -293
  168. package/test/unit/services/github.test.ts +0 -200
  169. package/test/unit/state/actions.test.ts +0 -217
  170. package/test/unit/state/reducer.test.ts +0 -745
  171. package/test/unit/state/store.test.tsx +0 -711
  172. package/test/unit/types/commands.test.ts +0 -220
  173. package/test/unit/types/schema.test.ts +0 -179
  174. package/test/unit/utils/array.test.ts +0 -73
  175. package/test/unit/utils/debug.test.ts +0 -23
  176. package/test/unit/utils/errors.test.ts +0 -295
  177. package/test/unit/utils/markdown.test.ts +0 -163
  178. package/test/unit/utils/project-utils.test.ts +0 -756
  179. package/test/unit/utils/rate-limiter.test.ts +0 -256
  180. package/test/unit/utils/retry.test.ts +0 -165
  181. package/test/unit/utils/strip-ansi.ts +0 -13
  182. package/test/unit/utils/timeout.test.ts +0 -93
  183. 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
- });