main
mjs 302 lines 18.5 KB
Raw
1 import assert from "node:assert/strict";
2 import { readFileSync } from "node:fs";
3 import test from "node:test";
4
5 const workspace = readFileSync(new URL("../app/components/investment-workspace.tsx", import.meta.url), "utf8");
6 const api = readFileSync(new URL("../app/lib/portfolio-api.ts", import.meta.url), "utf8");
7 const styles = readFileSync(new URL("../app/styles.css", import.meta.url), "utf8");
8
9 test("renders persisted multi-portfolio totals while keeping holdings out of the dashboard", () => {
10 assert.match(workspace, />All<\/button>/);
11 assert.match(workspace, /Object\.entries\(dashboard\.currencyTotals\)/);
12 assert.match(workspace, /dashboard\.portfolios\.map/);
13 assert.doesNotMatch(workspace, /dashboard\.combinedHoldings\.map/);
14 });
15
16 test("manual-import display-name editing lives inside the holding drawer", () => {
17 assert.match(workspace, /position\.sourceType === "MANUAL_CSV_IMPORT"/);
18 assert.match(workspace, />Edit<\/button>/);
19 assert.match(workspace, />Save<\/Button>/);
20 assert.match(workspace, />Cancel<\/Button>/);
21 assert.doesNotMatch(workspace, />Edit name<\/button>/);
22 assert.match(workspace, /maxLength=\{160\}/);
23 assert.match(api, /positions\/\$\{positionId\}\/display-name/);
24 assert.doesNotMatch(workspace, /dangerouslySetInnerHTML/);
25 assert.match(workspace, /<strong>\{position\.displayName\}<\/strong>/);
26 });
27
28 test("research selection uses global instrument identity rather than a display ticker alias", () => {
29 assert.match(api, /globalInstrumentId\?: string \| null/);
30 assert.match(workspace, /company\.instrumentId === globalInstrumentId/);
31 assert.match(workspace, /value: globalInstrumentId \?\? resolved\?\.instrumentId \?\? instrument\.instrumentId/);
32 assert.match(workspace, /globalInstrumentId,/);
33 assert.match(workspace, /canRefreshResearch\(company\)/);
34 assert.doesNotMatch(workspace, /KPITE\s*[-=]>\s*KPITTECH/);
35 });
36
37 test("refresh eligibility is based on canonical global identity, not a portfolio-summary row or existing research", () => {
38 const eligibility = workspace.match(/function canRefreshResearch[\s\S]*?\n}\n\nfunction researchEmptyTitle/)?.[0] ?? "";
39 assert.match(eligibility, /Boolean\(company\?\.instrumentId\)/);
40 assert.doesNotMatch(eligibility, /documentCount|eventCount|confidence|freshness|lastRefresh/);
41 assert.match(eligibility, /"COMPANY_NOT_RESOLVED"/);
42 assert.match(eligibility, /"RESEARCH_NOT_APPLICABLE"/);
43 assert.match(eligibility, /"ETF_UNSUPPORTED"/);
44 assert.match(workspace, /function canRefreshResearchIdentity\([\s\S]*?Boolean\(globalInstrumentId\?\.trim\(\)\)/);
45 assert.match(workspace, /selectedContextResearchCompany\?\.status\s*\?\? \(selectedContextResearchCompany \? undefined : selectedResearchOption\?\.status\)/);
46 assert.match(workspace, /disabled=\{loading \|\| !refreshEligible\}/);
47 assert.match(workspace, /selectedResearchInstrumentId=\{selectedResearchInstrumentId\}/);
48 assert.match(workspace, /openResearchReadiness\([\s\S]*?selectedResearchInstrumentId/);
49 });
50
51 test("Talbros/Ujjivan-shaped global selections are refreshable while unresolved and unsupported selections are not", () => {
52 const eligibility = workspace.match(/function canRefreshResearchIdentity[\s\S]*?\n}\n\nfunction researchEmptyTitle/)?.[0] ?? "";
53 // The helper deliberately ignores documents/events/confidence/freshness and
54 // gates only canonical identity, active loading, explicit status, and asset type.
55 assert.doesNotMatch(eligibility, /documentCount|eventCount|confidence|freshness|lastRefresh/);
56 assert.match(workspace, /globalInstrumentId \? "GLOBAL_INSTRUMENT_RESOLVED" : "COMPANY_NOT_RESOLVED"/);
57 assert.match(eligibility, /"COMPANY_NOT_RESOLVED"/);
58 assert.match(eligibility, /"ETF_UNSUPPORTED"/);
59 assert.match(eligibility, /\["ETF", "FUND", "BOND", "CASH", "CRYPTO"\]/);
60 assert.match(workspace, /value: globalInstrumentId \?\? resolved\?\.instrumentId/);
61 assert.match(workspace, /openResearchReadiness\([\s\S]*?selectedResearchInstrumentId/);
62 });
63
64 test("portfolio detail and research-table drawers join global research to a different local holding ID", () => {
65 const holdingsView = workspace.match(/function HoldingsTable\([\s\S]*?\n}\n\nfunction metricText/)?.[0] ?? "";
66 assert.match(holdingsView, /company\.instrumentId === position\.instrument\.globalInstrumentId/);
67 assert.match(holdingsView, /company\.instrumentId === position\.instrument\.instrumentId/);
68 assert.match(holdingsView, /<StockResearchDrawer position=\{detail\.position\} research=\{detail\.researchInstrumentId \? portfolioResearch\?\.companies\.find/);
69 assert.match(workspace, /position\.instrument\.globalInstrumentId === company\.instrumentId/);
70 assert.match(workspace, /market\?\.resolution\.providerTicker/);
71 assert.match(workspace, /research\?\.structuredMarket\?\.facts\[key\]/);
72 assert.match(workspace, /latestQuarterlyResult|shareholdingChanges|currentQuarterCatalysts|sourceDiversity/);
73 });
74
75 test("renders public broker cards and hides the demo provider in normal mode", () => {
76 assert.match(workspace, /visibleProviders\.map/);
77 assert.match(workspace, /provider\.brokerType !== "MOCK"/);
78 assert.match(workspace, /provider\.connectable/);
79 assert.match(workspace, /provider\.unavailableReason \?\? "Connection not available yet\."/);
80 assert.match(api, /\/api\/v1\/brokers/);
81 });
82
83 test("uses generic sync and authentication continuation contracts", () => {
84 assert.match(api, /portfolios\/broker-connections\/\$\{connectionId\}\/sync/);
85 assert.match(workspace, /refreshAuthenticatedBroker/);
86 assert.match(workspace, /REDIRECT_REQUIRED/);
87 });
88
89 test("consumer broker flow never asks for retail API credentials", () => {
90 assert.match(api, /PARTNER_AUTH_UNAVAILABLE/);
91 assert.doesNotMatch(workspace, /USER_CREDENTIALS_REQUIRED|broker password|OTP|MFA/);
92 assert.match(workspace, /provider\.individualApiSupported && provider\.advancedIndividualMode/);
93 assert.match(workspace, /activeConnection\.brokerType === "IBKR" \? "Connect \/ Re-authenticate" : "Connect"/);
94 });
95
96 test("opens a generic broker authentication popup synchronously before API work", () => {
97 assert.match(workspace, /window\.open\("about:blank", "aip-ibkr-auth", features\)/);
98 assert.match(workspace, /popup=yes,width=\$\{width\},height=\$\{height\},left=\$\{left\},top=\$\{top\},resizable=yes,scrollbars=yes/);
99 assert.match(workspace, /const authWindow = openBrokerAuthenticationWindow\(provider\.brokerType\);[\s\S]*?await brokerApi\.connectBroker/);
100 assert.match(workspace, /authWindow\.location\.assign\(action\.authenticationUrl\)/);
101 assert.match(workspace, /action\.action === "REDIRECT_REQUIRED" \|\| action\.action === "POPUP_REQUIRED"/);
102 });
103
104 test("popup flow handles blocked windows, NONE, failures, and missing URLs safely", () => {
105 assert.match(workspace, /Your browser blocked the IBKR sign-in window\. Allow pop-ups for this site and try again\./);
106 assert.match(workspace, /if \(action\.action === "NONE"\)/);
107 assert.match(workspace, /if \(!action\.authenticationUrl\)/);
108 assert.match(workspace, /authWindow\.close\(\)/);
109 assert.match(workspace, /Your saved portfolio remains available/);
110 });
111
112 test("popup handling is provider-neutral and unsupported providers never receive a URL", () => {
113 assert.doesNotMatch(workspace, /authenticationUrl.*IBKR|authenticationUrl.*ICICI|authenticationUrl.*HDFC/);
114 assert.match(api, /"UNAVAILABLE" \| "UNSUPPORTED"/);
115 });
116
117 test("keeps market-price and broker-sync freshness visibly separate", () => {
118 assert.match(workspace, /Broker holdings synced:/);
119 assert.match(workspace, /Market price updated/);
120 });
121
122 test("authentication-required IBKR uses re-authentication and broker cards do not offer Sync", () => {
123 const brokerView = workspace.match(/function BrokerView\([\s\S]*?\nfunction ResearchView/)?.[0] ?? "";
124 assert.match(brokerView, /Connect \/ Re-authenticate/);
125 assert.doesNotMatch(brokerView, />Sync</);
126 assert.match(brokerView, /Scalable broker authentication|unavailableReason/);
127 });
128
129 test("connected IBKR maps to Manage and Disconnect", () => {
130 const brokerView = workspace.match(/function BrokerView\([\s\S]*?\nfunction ResearchView/)?.[0] ?? "";
131 assert.match(brokerView, /activeConnection && connected \? <Button variant="secondary" disabled>Manage<\/Button>/);
132 assert.match(brokerView, />Disconnect<\/Button>/);
133 });
134
135 test("disconnected and error IBKR remain recoverable instead of Disconnect-only", () => {
136 const brokerView = workspace.match(/function BrokerView\([\s\S]*?\nfunction ResearchView/)?.[0] ?? "";
137 assert.match(brokerView, /const recoverable = Boolean\(activeConnection\) && !connected/);
138 assert.match(brokerView, /activeConnection && recoverable/);
139 assert.match(brokerView, /Connect \/ Re-authenticate/);
140 });
141
142 test("authentication-required IBKR exposes the reconnect action", () => {
143 assert.match(workspace, /authenticationRequired[\s\S]*?recoverable/);
144 assert.match(workspace, /activeConnection\.brokerType === "IBKR" \? "Connect \/ Re-authenticate" : "Connect"/);
145 });
146
147 test("HDFC and ICICI partner-unavailable Connect does not open a popup", () => {
148 assert.match(workspace, /provider\.consumerAuthMode === "PARTNER_UNAVAILABLE"[\s\S]*?Direct customer account connection is not available yet\.[\s\S]*?return;[\s\S]*?openBrokerAuthenticationWindow/);
149 assert.match(api, /consumerAuthMode: "BROKER_REDIRECT" \| "PARTNER_OAUTH" \| "INDIVIDUAL_API_CREDENTIALS" \| "PARTNER_UNAVAILABLE" \| "NONE"/);
150 });
151
152 test("partner-unavailable and authentication errors are broker-card-local", () => {
153 const brokerView = workspace.match(/function BrokerView\([\s\S]*?\nfunction ResearchView/)?.[0] ?? "";
154 assert.match(brokerView, /errors\[provider\.brokerType\]/);
155 assert.doesNotMatch(brokerView, /\{error \? <div className="broker-inline-error"/);
156 });
157
158 test("broker authentication failure preserves the Brokers page", () => {
159 assert.match(workspace, /setBrokerCardError\(brokerType, getApiFailure\(err\)\.message\)/);
160 assert.match(workspace, /visibleProviders\.map/);
161 assert.doesNotMatch(workspace, /setError\(getApiFailure\(err\)\)[\s\S]{0,120}completeBrokerAuthentication/);
162 });
163
164 test("normal broker rendering consumes the backend canonical connection list", () => {
165 const brokerView = workspace.match(/function BrokerView\([\s\S]*?\nfunction ResearchView/)?.[0] ?? "";
166 assert.match(api, /listConnections: \(\) => request<BrokerConnection\[]>\("\/api\/v1\/broker-connections"\)/);
167 assert.match(brokerView, /connections\.find\(\(connection\) => connection\.brokerType === provider\.brokerType\)/);
168 assert.match(brokerView, /linkedPortfolio[\s\S]*?portfolio\.brokerConnectionId === activeConnection\.connectionId/);
169 });
170
171 test("partner unavailable has a dedicated badge and no meaningless disconnect", () => {
172 const brokerView = workspace.match(/function BrokerView\([\s\S]*?\nfunction ResearchView/)?.[0] ?? "";
173 assert.match(brokerView, /partnerUnavailable \? "Direct connection unavailable"/);
174 assert.match(brokerView, /Direct customer account connection is not available yet\./);
175 assert.match(brokerView, /meaningfulPersistedLinkage/);
176 assert.match(brokerView, /!partnerUnavailable \|\| meaningfulPersistedLinkage/);
177 assert.match(brokerView, /!partnerUnavailable && !developerIndividualMode && !activeConnection && provider\.connectable/);
178 assert.match(brokerView, /!partnerUnavailable && !developerIndividualMode && activeConnection && recoverable/);
179 assert.match(brokerView, /!partnerUnavailable && errors\[provider\.brokerType\]/);
180 });
181
182 test("provider metadata drives partner, unavailable, and advanced individual modes", () => {
183 assert.match(api, /"PARTNER_OAUTH" \| "INDIVIDUAL_API_CREDENTIALS" \| "PARTNER_UNAVAILABLE"/);
184 assert.match(api, /individualApiSupported: boolean/);
185 assert.match(api, /advancedIndividualMode: boolean/);
186 assert.match(workspace, /developerIndividualMode \? \(/);
187 assert.match(workspace, /Configure DEV API/);
188 assert.match(workspace, /Credentials are stored write-only/);
189 assert.match(workspace, /setDeveloperSecret\(""\)/);
190 assert.doesNotMatch(workspace, /provider\.brokerType === "HDFC_SECURITIES".*developer|provider\.brokerType === "ICICI_DIRECT".*developer/);
191 });
192
193 test("IBKR redirect keeps its popup open and NONE closes it", () => {
194 assert.match(workspace, /action\.action === "REDIRECT_REQUIRED" \|\| action\.action === "POPUP_REQUIRED"/);
195 assert.match(workspace, /authWindow\.location\.assign\(action\.authenticationUrl\)[\s\S]*?return true/);
196 assert.match(workspace, /authWindow\.close\(\);[\s\S]*?if \(action\.action === "NONE"\)/);
197 });
198
199 test("saved IBKR portfolio remains rendered while authentication is required", () => {
200 const brokerView = workspace.match(/function BrokerView\([\s\S]*?\nfunction ResearchView/)?.[0] ?? "";
201 assert.match(brokerView, /Authentication is required to refresh holdings\. Your saved portfolio remains available\./);
202 assert.match(brokerView, /linkedPortfolio \? <p className="broker-linked-portfolio">/);
203 });
204
205 test("Sync is rendered only when a linked broker portfolio supplies an action", () => {
206 assert.match(workspace, /selectedPortfolio\?\.brokerConnectionId \? syncSelectedPortfolio : undefined/);
207 assert.match(workspace, /\{onSync \? <Button/);
208 });
209
210 test("broker status badges and actions cannot wrap character by character", () => {
211 assert.match(styles, /\.broker-card \.badge,[\s\S]*?white-space: nowrap/);
212 assert.match(styles, /word-break: normal/);
213 });
214
215 test("persisted broker portfolios remain visible with unknown historical sync time", () => {
216 assert.match(workspace, /Imported previously; sync time unknown/);
217 assert.match(workspace, /Your saved portfolio remains available/);
218 });
219
220 test("does not expose connector or session internals in frontend connection types", () => {
221 const publicConnectionType = api.match(/export type BrokerConnection = \{[\s\S]*?\n\};/)?.[0] ?? "";
222 assert.doesNotMatch(publicConnectionType, /connectorId/);
223 assert.doesNotMatch(publicConnectionType, /sessionReference/);
224 assert.doesNotMatch(publicConnectionType, /externalAccountReference/);
225 const descriptorType = api.match(/export type BrokerProviderInfo = \{[\s\S]*?\n\};/)?.[0] ?? "";
226 assert.doesNotMatch(descriptorType, /authenticationModel|capabilities|connectionMethod/);
227 });
228
229 test("manual import uses the exact multipart preview contract and derives browser-suffixed accounts", () => {
230 assert.match(api, /body\.append\("file", file\)/);
231 assert.match(api, /init\?\.body instanceof FormData \? \{\} : \{ "Content-Type": "application\/json" \}/);
232 assert.doesNotMatch(api, /previewImport[\s\S]{0,500}"Content-Type": "multipart\/form-data"/);
233 assert.match(api, /imports\/\$\{brokerType\.toLowerCase\(\)\}\/preview/);
234 assert.match(workspace, /detectedImportAccount/);
235 assert.match(workspace, /PortFolioEqtSummary/);
236 assert.match(workspace, /Invest Right Equity Portfolio_/);
237 assert.match(workspace, /Preview portfolio/);
238 });
239
240 test("manual import is a polished dialog with statement prices and useful backend errors", () => {
241 assert.match(workspace, /role="dialog"/);
242 assert.match(workspace, /import-modal-backdrop/);
243 assert.match(workspace, /Statement price/);
244 assert.match(workspace, /getApiFailure\(error\)\.message/);
245 assert.match(styles, /\.import-dropzone/);
246 assert.match(workspace, /Portfolio imported successfully/);
247 });
248
249 test("manual portfolios expose explicit price refresh without a selection-triggered sweep", () => {
250 assert.match(api, /refreshPrices: \(portfolioId: string\)/);
251 assert.match(api, /\/prices\/refresh/);
252 assert.match(api, /importedPrice\?: Money \| null/);
253 assert.match(workspace, /acquisitionSource === "MANUAL_CSV_IMPORT" \? refreshSelectedPrices/);
254 assert.doesNotMatch(workspace, /automaticallyRefreshedPricesRef/);
255 assert.doesNotMatch(workspace, /void refreshSelectedPrices\(\)/);
256 assert.match(workspace, /Refresh Prices/);
257 assert.match(workspace, /Latest Price/);
258 assert.match(workspace, /Imported Price/);
259 assert.match(workspace, /Provider/);
260 assert.match(workspace, /Market As Of/);
261 assert.match(workspace, /Retrieved At/);
262 });
263
264 test("IBKR authentication completion message resumes the canonical connection", () => {
265 assert.match(workspace, /aip:ibkr-authenticated/);
266 assert.match(workspace, /event\.origin !== window\.location\.origin/);
267 assert.match(workspace, /finishBrokerAuthentication\(pending\.connectionId\)/);
268 });
269
270 test("IBKR popup lifecycle retains one popup and polls the canonical connection", () => {
271 assert.match(workspace, /brokerAuthPopupRef = useRef<Window \| null>/);
272 assert.match(workspace, /brokerAuthPopupRef\.current\.focus\(\)/);
273 assert.match(workspace, /pendingBrokerAuthRef\.current = \{ connectionId, provider \}/);
274 assert.match(workspace, /window\.setInterval\(\(\) => void poll\(\), brokerAuthenticationPollMs\)/);
275 assert.match(workspace, /brokerApi\.getAuthStatus\(connectionId\)/);
276 assert.match(workspace, /if \(!authStatus\.authenticated \|\| authStatus\.state !== "CONNECTED"\) return false/);
277 assert.match(workspace, /brokerApi\.authenticationAction\(connectionId\)/);
278 assert.match(workspace, /brokerApi\.listConnections\(\)/);
279 assert.match(workspace, /portfolioApi\.getDashboard\(\)/);
280 assert.match(workspace, /portfolioApi\.getPositions\(linkedPortfolio\.portfolioId\)/);
281 assert.match(workspace, /stopBrokerAuthenticationMonitoring\(true\)/);
282 assert.match(workspace, /if \(authWindow\.closed\)[\s\S]*?refreshAuthenticatedBroker\(connectionId\)[\s\S]*?stopBrokerAuthenticationMonitoring\(false\)/);
283 assert.match(workspace, /Waiting for IBKR authentication…/);
284 assert.match(workspace, /!authenticationPending[\s\S]*?onAuthenticate/);
285 });
286
287 test("research uses a clean company name while preserving structured identity", () => {
288 assert.match(workspace, /function legacyCompositeCompanyName/);
289 assert.match(workspace, /segments\.length >= 3 && segments\[2\] \? segments\[2\]/);
290 assert.match(workspace, /position\.customDisplayName\?\.trim\(\)/);
291 assert.match(workspace, /instrument\.companyName\?\.trim\(\)/);
292 assert.match(workspace, /instrument\.canonicalName\?\.trim\(\) \|\| resolved\?\.companyName\?\.trim\(\)/);
293 assert.match(workspace, /ticker: displayTicker,[\s\S]*?exchange: displayExchange/);
294 assert.match(workspace, /\{option\.companyName\}/);
295 assert.doesNotMatch(workspace, /label: `\$\{displayTicker\} \/ \$\{displayExchange\} \/ /);
296 assert.match(workspace, /title=\{option\.companyName\}/);
297 assert.match(workspace, /join\(" · "\)/);
298 assert.match(styles, /\.research-company-select option[\s\S]*?background: var\(--research-option-bg\)[\s\S]*?color: var\(--research-option-text\)/);
299 assert.match(styles, /\.research-company-select option:hover,[\s\S]*?\.research-company-select option:checked/);
300 assert.match(styles, /\.research-company-select:focus-visible/);
301 assert.match(styles, /text-overflow: ellipsis/);
302 });