Skip to content

Commit 884b58b

Browse files
authored
🐛 Wire authService and projectService into TDD server (#149)
## Summary The TDD dashboard Projects and Builds tabs were showing "Not signed in" even when the user was authenticated via `vizzly login`. This was because the TDD command only passed `configService` to the server manager, leaving `authService` and `projectService` undefined. **Root cause:** In `src/commands/tdd.js:124`, only `configService` was passed to `createServerManager()`. The HTTP server's auth and project routers checked for these services and returned "Service unavailable" when they were undefined. **Changes:** - Add `auth-service.js` - wraps auth operations (`isAuthenticated`, `whoami`, `initiateDeviceFlow`, etc.) for the HTTP server - Add `project-service.js` - wraps project operations (`listProjects`, `listMappings`, `getRecentBuilds`, etc.) for the HTTP server - Wire both services into the TDD command alongside `configService` - Add unit tests for both new services ## Test plan - [x] Run `vizzly login` to authenticate - [x] Run `vizzly tdd start` - [x] Open http://localhost:47392 and navigate to Projects tab - [x] Verify user is shown as signed in (not "Not signed in") - [x] Verify project mappings load correctly - [x] Run unit tests: `node --test tests/services/auth-service.test.js tests/services/project-service.test.js`
1 parent 33fd6c6 commit 884b58b

File tree

7 files changed

+953
-2
lines changed

7 files changed

+953
-2
lines changed

src/commands/run.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ export async function runCommand(
206206
});
207207

208208
// Create server manager (functional object)
209+
// Note: Unlike TDD mode, run command doesn't need authService/projectService
210+
// because it has no interactive dashboard - it's a one-shot CI command
209211
serverManager = createServerManager(configWithVerbose, {});
210212

211213
// Create build manager (functional object)

src/commands/tdd.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import {
1212
} from '../api/index.js';
1313
import { VizzlyError } from '../errors/vizzly-error.js';
1414
import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js';
15+
import { createAuthService as defaultCreateAuthService } from '../services/auth-service.js';
1516
import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js';
1617
import { createConfigService as defaultCreateConfigService } from '../services/config-service.js';
18+
import { createProjectService as defaultCreateProjectService } from '../services/project-service.js';
1719
import {
1820
initializeDaemon as defaultInitializeDaemon,
1921
runTests as defaultRunTests,
@@ -47,7 +49,9 @@ export async function tddCommand(
4749
getBuild = defaultGetBuild,
4850
createServerManager = defaultCreateServerManager,
4951
createBuildObject = defaultCreateBuildObject,
52+
createAuthService = defaultCreateAuthService,
5053
createConfigService = defaultCreateConfigService,
54+
createProjectService = defaultCreateProjectService,
5155
initializeDaemon = defaultInitializeDaemon,
5256
runTests = defaultRunTests,
5357
detectBranch = defaultDetectBranch,
@@ -117,11 +121,17 @@ export async function tddCommand(
117121
output.startSpinner('Initializing TDD server...');
118122
let configWithVerbose = { ...config, verbose: globalOptions.verbose };
119123

120-
// Create config service for dashboard settings page
124+
// Create services for dashboard tabs
121125
let configService = createConfigService({ workingDir: process.cwd() });
126+
let authService = createAuthService();
127+
let projectService = createProjectService();
122128

123129
// Create server manager (functional object)
124-
serverManager = createServerManager(configWithVerbose, { configService });
130+
serverManager = createServerManager(configWithVerbose, {
131+
configService,
132+
authService,
133+
projectService,
134+
});
125135

126136
// Create build manager (functional object that provides the interface runTests expects)
127137
let buildManager = {

src/services/auth-service.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Auth Service
3+
* Wraps auth operations for use by the HTTP server
4+
*
5+
* Provides the interface expected by src/server/routers/auth.js:
6+
* - isAuthenticated() - Returns boolean, false if no tokens or API call fails
7+
* - whoami() - Throws if not authenticated or tokens invalid
8+
* - initiateDeviceFlow() - Throws on API error
9+
* - pollDeviceAuthorization(deviceCode) - Returns pending status or tokens, throws on error
10+
* - completeDeviceFlow(tokens) - Saves tokens to global config
11+
* - logout() - Clears local tokens, may warn if server revocation fails
12+
* - authenticatedRequest(endpoint, options) - Throws 'Not authenticated' if no tokens
13+
*
14+
* Error handling:
15+
* - isAuthenticated() never throws, returns false on any error
16+
* - whoami() throws if tokens are missing/invalid (caller should check isAuthenticated first)
17+
* - authenticatedRequest() throws 'Not authenticated' if no access token
18+
* - Device flow methods throw on API errors (network, server errors)
19+
*/
20+
21+
import { createAuthClient } from '../auth/client.js';
22+
import * as authOps from '../auth/operations.js';
23+
import { getApiUrl } from '../utils/environment-config.js';
24+
import {
25+
clearAuthTokens,
26+
getAuthTokens,
27+
saveAuthTokens,
28+
} from '../utils/global-config.js';
29+
30+
/**
31+
* Create an auth service instance
32+
* @param {Object} [options]
33+
* @param {string} [options.apiUrl] - API base URL (defaults to VIZZLY_API_URL or https://app.vizzly.dev)
34+
* @param {Object} [options.httpClient] - Injectable HTTP client (for testing)
35+
* @param {Object} [options.tokenStore] - Injectable token store (for testing)
36+
* @returns {Object} Auth service
37+
*/
38+
export function createAuthService(options = {}) {
39+
let apiUrl = options.apiUrl || getApiUrl();
40+
41+
// Create HTTP client for API requests (uses auth client for proper auth handling)
42+
// Allow injection for testing
43+
let httpClient = options.httpClient || createAuthClient({ baseUrl: apiUrl });
44+
45+
// Create token store adapter for global config
46+
// Allow injection for testing
47+
let tokenStore = options.tokenStore || {
48+
getTokens: getAuthTokens,
49+
saveTokens: saveAuthTokens,
50+
clearTokens: clearAuthTokens,
51+
};
52+
53+
return {
54+
/**
55+
* Check if user is authenticated
56+
* @returns {Promise<boolean>}
57+
*/
58+
async isAuthenticated() {
59+
return authOps.isAuthenticated(httpClient, tokenStore);
60+
},
61+
62+
/**
63+
* Get current user information
64+
* @returns {Promise<Object>} User and organization data
65+
*/
66+
async whoami() {
67+
return authOps.whoami(httpClient, tokenStore);
68+
},
69+
70+
/**
71+
* Initiate OAuth device flow
72+
* @returns {Promise<Object>} Device code info
73+
*/
74+
async initiateDeviceFlow() {
75+
return authOps.initiateDeviceFlow(httpClient);
76+
},
77+
78+
/**
79+
* Poll for device authorization
80+
* @param {string} deviceCode
81+
* @returns {Promise<Object>} Token data or pending status
82+
*/
83+
async pollDeviceAuthorization(deviceCode) {
84+
return authOps.pollDeviceAuthorization(httpClient, deviceCode);
85+
},
86+
87+
/**
88+
* Complete device flow and save tokens
89+
* @param {Object} tokens - Token data
90+
* @returns {Promise<Object>}
91+
*/
92+
async completeDeviceFlow(tokens) {
93+
return authOps.completeDeviceFlow(tokenStore, tokens);
94+
},
95+
96+
/**
97+
* Logout and revoke tokens
98+
* @returns {Promise<void>}
99+
*/
100+
async logout() {
101+
return authOps.logout(httpClient, tokenStore);
102+
},
103+
104+
/**
105+
* Refresh access token
106+
* @returns {Promise<Object>} New tokens
107+
*/
108+
async refresh() {
109+
return authOps.refresh(httpClient, tokenStore);
110+
},
111+
112+
/**
113+
* Make an authenticated request to the API
114+
* Used by cloud-proxy router for proxying requests
115+
* @param {string} endpoint - API endpoint
116+
* @param {Object} options - Fetch options
117+
* @returns {Promise<Object>} Response data
118+
*/
119+
async authenticatedRequest(endpoint, options = {}) {
120+
let auth = await tokenStore.getTokens();
121+
if (!auth?.accessToken) {
122+
throw new Error('Not authenticated');
123+
}
124+
return httpClient.authenticatedRequest(
125+
endpoint,
126+
auth.accessToken,
127+
options
128+
);
129+
},
130+
};
131+
}

src/services/project-service.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Project Service
3+
* Wraps project operations for use by the HTTP server
4+
*
5+
* Provides the interface expected by src/server/routers/projects.js:
6+
* - listProjects() - Returns [] if not authenticated
7+
* - listMappings() - Returns [] if no mappings
8+
* - getMapping(directory) - Returns null if not found
9+
* - createMapping(directory, projectData) - Throws on invalid input
10+
* - removeMapping(directory) - Throws on invalid directory
11+
* - getRecentBuilds(projectSlug, organizationSlug, options) - Returns [] if not authenticated
12+
*
13+
* Error handling:
14+
* - API methods (listProjects, getRecentBuilds) return empty arrays when not authenticated
15+
* - Local methods (listMappings, getMapping) never require authentication
16+
* - Validation errors (createMapping, removeMapping) throw with descriptive messages
17+
*/
18+
19+
import { createAuthClient } from '../auth/client.js';
20+
import * as projectOps from '../project/operations.js';
21+
import { getApiUrl } from '../utils/environment-config.js';
22+
import {
23+
deleteProjectMapping,
24+
getAuthTokens,
25+
getProjectMapping,
26+
getProjectMappings,
27+
saveProjectMapping,
28+
} from '../utils/global-config.js';
29+
30+
/**
31+
* Create a project service instance
32+
* @param {Object} [options]
33+
* @param {string} [options.apiUrl] - API base URL (defaults to VIZZLY_API_URL or https://app.vizzly.dev)
34+
* @param {Object} [options.httpClient] - Injectable HTTP client (for testing)
35+
* @param {Object} [options.mappingStore] - Injectable mapping store (for testing)
36+
* @param {Function} [options.getAuthTokens] - Injectable token getter (for testing)
37+
* @returns {Object} Project service
38+
*/
39+
export function createProjectService(options = {}) {
40+
let apiUrl = options.apiUrl || getApiUrl();
41+
42+
// Create HTTP client once at service creation (not per-request)
43+
// Allow injection for testing
44+
let httpClient = options.httpClient || createAuthClient({ baseUrl: apiUrl });
45+
46+
// Create mapping store adapter for global config
47+
// Allow injection for testing
48+
let mappingStore = options.mappingStore || {
49+
getMappings: getProjectMappings,
50+
getMapping: getProjectMapping,
51+
saveMapping: saveProjectMapping,
52+
deleteMapping: deleteProjectMapping,
53+
};
54+
55+
// Allow injection of getAuthTokens for testing
56+
let tokenGetter = options.getAuthTokens || getAuthTokens;
57+
58+
/**
59+
* Create an OAuth client with current access token
60+
* @returns {Promise<Object|null>} OAuth client or null if not authenticated
61+
*/
62+
async function createOAuthClient() {
63+
let auth = await tokenGetter();
64+
if (!auth?.accessToken) {
65+
return null;
66+
}
67+
68+
// Wrap authenticatedRequest to auto-inject the access token
69+
return {
70+
authenticatedRequest: (endpoint, fetchOptions = {}) =>
71+
httpClient.authenticatedRequest(
72+
endpoint,
73+
auth.accessToken,
74+
fetchOptions
75+
),
76+
};
77+
}
78+
79+
return {
80+
/**
81+
* List all projects from API
82+
* Returns empty array if not authenticated (projectOps handles null oauthClient)
83+
* @returns {Promise<Array>} Array of projects, empty if not authenticated
84+
*/
85+
async listProjects() {
86+
let oauthClient = await createOAuthClient();
87+
// projectOps.listProjects handles null oauthClient by returning []
88+
return projectOps.listProjects({ oauthClient, apiClient: null });
89+
},
90+
91+
/**
92+
* List all project mappings
93+
* @returns {Promise<Array>} Array of project mappings
94+
*/
95+
async listMappings() {
96+
return projectOps.listMappings(mappingStore);
97+
},
98+
99+
/**
100+
* Get project mapping for a specific directory
101+
* @param {string} directory - Directory path
102+
* @returns {Promise<Object|null>} Project mapping or null
103+
*/
104+
async getMapping(directory) {
105+
return projectOps.getMapping(mappingStore, directory);
106+
},
107+
108+
/**
109+
* Create or update project mapping
110+
* @param {string} directory - Directory path
111+
* @param {Object} projectData - Project data
112+
* @returns {Promise<Object>} Created mapping
113+
*/
114+
async createMapping(directory, projectData) {
115+
return projectOps.createMapping(mappingStore, directory, projectData);
116+
},
117+
118+
/**
119+
* Remove project mapping
120+
* @param {string} directory - Directory path
121+
* @returns {Promise<void>}
122+
*/
123+
async removeMapping(directory) {
124+
return projectOps.removeMapping(mappingStore, directory);
125+
},
126+
127+
/**
128+
* Get recent builds for a project
129+
* Returns empty array if not authenticated (projectOps handles null oauthClient)
130+
* @param {string} projectSlug - Project slug
131+
* @param {string} organizationSlug - Organization slug
132+
* @param {Object} options - Query options
133+
* @returns {Promise<Array>} Array of builds, empty if not authenticated
134+
*/
135+
async getRecentBuilds(projectSlug, organizationSlug, options = {}) {
136+
let oauthClient = await createOAuthClient();
137+
// projectOps.getRecentBuilds handles null oauthClient by returning []
138+
return projectOps.getRecentBuilds({
139+
oauthClient,
140+
apiClient: null,
141+
projectSlug,
142+
organizationSlug,
143+
limit: options.limit,
144+
branch: options.branch,
145+
});
146+
},
147+
};
148+
}

0 commit comments

Comments
 (0)