fix(identity): bootstrap canonical provider mappings on clean start
prakhar82 committed
Sep 12, 2026 at 19:47 UTC
38ecb0481ccaca2ec722d0d5c104186cefb37254
18 files changed
+517
-12
.gitignore
+1
@@ -87,3 +87,4 @@ smtp.password
87
# Local runtime/debug artifacts
88
kafka-deployment.txt
89
services/running-broker-jar-inspect/
90
+/artifacts/
ai/research-engine/app/main.py
+15
@@ -895,6 +895,21 @@ async def structured_market_snapshot(instrument: dict = Body(...)):
895
raise HTTPException(status_code=422, detail=str(exc)) from exc
896
897
898
+@app.post("/internal/v1/research/instruments/resolve-provider")
899
+async def resolve_provider_identity(instrument: dict = Body(...), x_aip_service_identity: str | None = Header(default=None)):
900
+ """Identity-only reconciliation. Never collects or persists research data."""
901
+ if x_aip_service_identity != "portfolio-service":
902
+ raise HTTPException(status_code=403, detail="INTERNAL_SERVICE_REQUIRED")
903
+ if (instrument.get("structuredNseCandidateSource") != "VERIFIED_NSE"
904
+ or not instrument.get("isin") or instrument.get("assetType") != "EQUITY"):
905
+ raise HTTPException(status_code=422, detail="VERIFIED_NSE_IDENTITY_REQUIRED")
906
+ try:
907
+ resolution = await portfolio_orchestrator.structured_provider.resolve_instrument(instrument)
908
+ return resolution.model_dump(mode="json")
909
+ except StructuredProviderError as exc:
910
+ raise HTTPException(status_code=503 if "UNAVAILABLE" in str(exc) else 422, detail=str(exc)) from exc
911
+
912
+
913
def _require_profile(instrument_id: UUID) -> None:
914
try:
915
repository.profile(instrument_id)
ai/research-engine/app/structured_market.py
+1
-1
@@ -170,7 +170,7 @@ class YahooFinanceProvider:
170
if reason is not None:
171
logger.info("yahoo_mapping_resolution globalInstrumentId=%s candidateSource=VERIFIED_NSE candidate=%s outcome=REJECTED reason=%s",
172
instrument.get("instrumentId"), candidate_symbol, reason)
173
- raise StructuredProviderError("COMPANY_NOT_RESOLVED")
173
+ raise StructuredProviderError("COMPANY_NOT_RESOLVED:" + reason)
174
logger.info("yahoo_mapping_resolution globalInstrumentId=%s candidateSource=VERIFIED_NSE candidate=%s outcome=VALIDATED reason=NONE",
175
instrument.get("instrumentId"), candidate_symbol)
176
return StructuredInstrumentResolution(
ai/research-engine/tests/test_identity_bootstrap.py
new
+74
@@ -0,0 +1,74 @@
1
+from uuid import uuid4
2
+from unittest.mock import AsyncMock
3
+import httpx
4
+import pytest
5
+from fastapi.testclient import TestClient
6
+from app import main
7
+from app.models import StructuredInstrumentResolution
8
+from app.structured_market import StructuredProviderError
9
+from test_structured_market import provider, response, instrument
10
+
11
+@pytest.mark.asyncio
12
+@pytest.mark.parametrize('symbol,isin,name', [('ALPHA','INE111A01010','Alpha Components Limited'),('BETA','INE222A01010','Beta Engineering Limited'),('GAMMA','INE333A01010','Gamma Services Limited')])
13
+async def test_trusted_official_identity_resolves_generically_without_research_acquisition(symbol,isin,name):
14
+ calls=[]
15
+ def handler(request):
16
+ calls.append(request.url.path)
17
+ assert request.url.params['newsCount']=='0'
18
+ return response({'quotes':[{'symbol':symbol+'.NS','exchange':'NSI','quoteType':'EQUITY','longname':name,'isin':isin}]})
19
+ resolved=await provider(handler).resolve_instrument(instrument(canonicalName=name,isin=isin,structuredNseCandidateTicker=symbol+'.NS',structuredNseCandidateSource='VERIFIED_NSE'))
20
+ assert resolved.provider_ticker==symbol+'.NS'
21
+ assert resolved.status=='VERIFIED_NSE_CANDIDATE'
22
+ assert calls==['/v1/finance/search']
23
+
24
+@pytest.mark.asyncio
25
+@pytest.mark.parametrize('case',['mismatched_isin','ambiguous','unavailable'])
26
+async def test_mapping_validation_fails_closed(case):
27
+ quote={'symbol':'ALPHA.NS','exchange':'NSI','quoteType':'EQUITY','longname':'Alpha Components Limited','isin':'INE111A01010'}
28
+ if case=='mismatched_isin':quote['isin']='INE222A01010'
29
+ def handler(request):return response({'quotes':[quote,quote] if case=='ambiguous' else [quote]},503 if case=='unavailable' else 200)
30
+ with pytest.raises(StructuredProviderError,match='UNAVAILABLE' if case=='unavailable' else 'COMPANY_NOT_RESOLVED'):
31
+ await provider(handler).resolve_instrument(instrument(canonicalName='Alpha Components Limited',isin='INE111A01010',structuredNseCandidateTicker='ALPHA.NS',structuredNseCandidateSource='VERIFIED_NSE'))
32
+
33
+def test_internal_identity_route_never_collects_snapshot(monkeypatch):
34
+ from datetime import datetime, timezone
35
+ resolver=AsyncMock(return_value=StructuredInstrumentResolution(instrument_id=uuid4(),provider='YAHOO_FINANCE',provider_ticker='ALPHA.NS',company_name='Alpha Components Limited',confidence=.95,resolved_at=datetime.now(timezone.utc),status='VERIFIED_NSE_CANDIDATE'))
36
+ collect=AsyncMock(side_effect=AssertionError('Research acquisition forbidden'))
37
+ monkeypatch.setattr(main.portfolio_orchestrator.structured_provider,'resolve_instrument',resolver)
38
+ monkeypatch.setattr(main.portfolio_orchestrator.structured_provider,'collect',collect)
39
+ payload={'assetType':'EQUITY','isin':'INE111A01010','structuredNseCandidateSource':'VERIFIED_NSE','structuredNseCandidateTicker':'ALPHA.NS'}
40
+ client=TestClient(main.app)
41
+ assert client.post('/internal/v1/research/instruments/resolve-provider',json=payload).status_code==403
42
+ assert client.post('/internal/v1/research/instruments/resolve-provider',json=payload,headers={'X-AIP-Service-Identity':'portfolio-service'}).status_code==200
43
+ collect.assert_not_called()
44
+
45
+@pytest.mark.asyncio
46
+async def test_targeted_ensure_uses_mapping_added_after_initial_canonical_read():
47
+ from app.repository import ResearchRepository
48
+ from app.persistence import SqliteResearchPersistence
49
+ from app.settings import Settings
50
+ from app.portfolio_orchestration import PortfolioResearchOrchestrator
51
+ from app.research_readiness_runtime import ResearchReadinessRuntime, RepositoryResearchReadinessAdapter
52
+ from app.yahoo_mcp_acquisition import McpFirstResearchCapabilityExecutor
53
+ from test_yahoo_mcp_acquisition import INSTRUMENT_ID, FakeGateway, FakeLegacy
54
+ repository=ResearchRepository(settings=Settings(research_live_enabled=False,research_demo_enabled=False),persistence=SqliteResearchPersistence())
55
+ orchestrator=PortfolioResearchOrchestrator(repository,repository.settings)
56
+ metadata={'globalInstrumentId':str(INSTRUMENT_ID),'canonicalName':'Ready Limited','isin':'INE111A01010','assetType':'EQUITY','currency':'INR','country':'IN','primaryExchange':'NSE','primarySymbol':'READY','status':'ACTIVE','providerMappings':[{'provider':'NSE','providerSymbol':'READY','status':'VERIFIED','exchange':'NSE','currency':'INR'}]}
57
+ assert orchestrator.register_global_profile_metadata(INSTRUMENT_ID,metadata)
58
+ assert 'YAHOO_FINANCE' not in repository.profile(INSTRUMENT_ID).provider_instrument_ids
59
+ metadata['providerMappings'].append({'provider':'YAHOO_FINANCE','providerSymbol':'READY.NS','status':'VERIFIED','exchange':'NSE','currency':'INR','resolutionSource':'YAHOO_FROM_VERIFIED_NSE'})
60
+ assert orchestrator.register_global_profile_metadata(INSTRUMENT_ID,metadata)
61
+ class VerifiedGateway(FakeGateway):
62
+ async def acquire_requirement(self,profile,**kwargs):
63
+ assert profile.instrument_id==INSTRUMENT_ID
64
+ assert profile.provider_instrument_ids['YAHOO_FINANCE']=='READY.NS'
65
+ return await super().acquire_requirement(profile,**kwargs)
66
+ gateway=VerifiedGateway()
67
+ legacy=FakeLegacy()
68
+ executor=McpFirstResearchCapabilityExecutor(legacy,repository,gateway,enabled=True)
69
+ runtime=ResearchReadinessRuntime(repository,RepositoryResearchReadinessAdapter(repository),executor)
70
+ result=await runtime.ensure(INSTRUMENT_ID,jurisdiction='INDIA',requirement_ids=['LATEST_PRICE'])
71
+ assert 'VERIFIED_YAHOO_MAPPING_REQUIRED' not in str(result.failures)
72
+ assert gateway.calls
73
+ assert repository.market_price_observations_for({INSTRUMENT_ID})[INSTRUMENT_ID]
74
+ assert repository.financial_facts_for(INSTRUMENT_ID)==[]
platform.ps1
+1
-1
@@ -320,7 +320,7 @@ function Ensure-DevSecrets {
320
321
$defaultUser = $env:AIP_SMTP_USERNAME
322
if (-not $defaultUser) { $defaultUser = "prakhar.unique@gmail.com" }
323
- $enteredUser = Read-Host "SMTP username [$defaultUser]"
323
+ $enteredUser = if ($env:AIP_SMTP_USERNAME) { $env:AIP_SMTP_USERNAME } else { Read-Host "SMTP username [$defaultUser]" }
324
if ([string]::IsNullOrWhiteSpace($enteredUser)) { $enteredUser = $defaultUser }
325
326
$smtpPasswordPlain = $env:AIP_SMTP_PASSWORD
services/api-gateway/src/main/java/com/aiinvestment/apigateway/PortfolioRouteController.java
+1
-1
@@ -192,7 +192,7 @@ public class PortfolioRouteController {
192
addTrustedIdentity(headers, request);
193
}
194
ResponseEntity<String> response = restClient.method(HttpMethod.valueOf(request.getMethod()))
195
- .uri(target)
195
+ .uri(java.net.URI.create(target))
196
.headers(outbound -> outbound.addAll(headers))
197
.body(body)
198
.exchange((clientRequest, clientResponse) -> {
services/api-gateway/src/test/java/com/aiinvestment/apigateway/PortfolioRouteControllerTest.java
+16
@@ -194,6 +194,22 @@ class PortfolioRouteControllerTest {
194
server.start();
195
}
196
197
+ @Test
198
+ void researchSearchPreservesEncodedCompanyNamesWithoutDoubleEncoding() throws Exception {
199
+ AtomicReference<String> query = new AtomicReference<>();
200
+ startServer(exchange -> {
201
+ query.set(exchange.getRequestURI().getRawQuery());
202
+ writeResponse(exchange, 200, "application/json", "[]".getBytes(StandardCharsets.UTF_8));
203
+ });
204
+ String local = "http://127.0.0.1:" + server.getAddress().getPort();
205
+ var controller = new PortfolioRouteController(local,local,local,local,local,local,local);
206
+ var request = new MockHttpServletRequest("GET", "/api/v1/research/instruments/search");
207
+ request.setQueryString("q=Alpha%20%26%20Beta®ion=INDIA&limit=15");
208
+ addTrustedIdentity(request);
209
+ assertThat(controller.routeResearch(request).getStatusCode()).isEqualTo(HttpStatus.OK);
210
+ assertThat(query.get()).isEqualTo(request.getQueryString());
211
+ }
212
+
213
private PortfolioRouteController controllerForServer() {
214
return new PortfolioRouteController(
215
"http://127.0.0.1:" + server.getAddress().getPort(),
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/CanonicalIdentityBootstrap.java
new
+98
@@ -0,0 +1,98 @@
1
+package com.aiinvestment.portfolio.application;
2
+
3
+import jakarta.annotation.PreDestroy;
4
+import org.slf4j.Logger;
5
+import org.slf4j.LoggerFactory;
6
+import org.springframework.beans.factory.annotation.Value;
7
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
8
+import org.springframework.scheduling.annotation.EnableScheduling;
9
+import org.springframework.scheduling.annotation.Scheduled;
10
+import org.springframework.stereotype.Component;
11
+import java.time.Instant;
12
+import java.util.*;
13
+import java.util.concurrent.*;
14
+import java.util.stream.Collectors;
15
+
16
+/** Explicit, leased, retryable reference/identity bootstrap. No research acquisition. */
17
+@Component
18
+@EnableScheduling
19
+@ConditionalOnProperty(name="market.identity-bootstrap.enabled", havingValue="true", matchIfMissing=true)
20
+public class CanonicalIdentityBootstrap {
21
+ private static final Logger log = LoggerFactory.getLogger(CanonicalIdentityBootstrap.class);
22
+ private final NseOfficialSecurityMaster official;
23
+ private final Nifty500ReferenceService nifty;
24
+ private final InstrumentMasterService instruments;
25
+ private final GlobalInstrumentReconciliationService reconciliation;
26
+ private final CanonicalIdentityBootstrapStore store;
27
+ private final String owner = UUID.randomUUID().toString();
28
+ private final ExecutorService workers = Executors.newFixedThreadPool(4);
29
+ private Instant providerRetryAfter = Instant.EPOCH;
30
+ @Value("${market.identity-bootstrap.batch-size:40}") private int batchSize = 40;
31
+
32
+ public CanonicalIdentityBootstrap(NseOfficialSecurityMaster official, Nifty500ReferenceService nifty,
33
+ InstrumentMasterService instruments, GlobalInstrumentReconciliationService reconciliation, CanonicalIdentityBootstrapStore store) {
34
+ this.official=official; this.nifty=nifty; this.instruments=instruments; this.reconciliation=reconciliation; this.store=store;
35
+ }
36
+
37
+ @Scheduled(initialDelayString="${market.identity-bootstrap.initial-delay-ms:15000}", fixedDelayString="${market.identity-bootstrap.delay-ms:5000}")
38
+ public void tick() {
39
+ Instant now = Instant.now();
40
+ if (now.isBefore(providerRetryAfter) || !store.claim(owner, now)) return;
41
+ try {
42
+ if (store.universeDue(now)) {
43
+ List<NseOfficialSecurityMaster.Listing> rows = official.listedEquities();
44
+ if (rows.isEmpty() || rows.size()>10000) throw new IllegalStateException("CANONICAL_BOOTSTRAP_FAILED");
45
+ var isinCounts = rows.stream().collect(Collectors.groupingBy(NseOfficialSecurityMaster.Listing::isin, Collectors.counting()));
46
+ var symbolCounts = rows.stream().collect(Collectors.groupingBy(r -> r.symbol().toUpperCase(Locale.ROOT), Collectors.counting()));
47
+ for (var row : rows) {
48
+ if (!Set.of("EQ","BE","BZ","SM","ST","IV").contains(row.series().toUpperCase(Locale.ROOT))) continue;
49
+ if (isinCounts.get(row.isin()) != 1 || symbolCounts.get(row.symbol().toUpperCase(Locale.ROOT)) != 1) {
50
+ log.warn("canonical_identity_bootstrap event=CANONICAL_BOOTSTRAP_FAILED reason=AMBIGUOUS_OFFICIAL_IDENTITY symbol={}", row.symbol());
51
+ continue;
52
+ }
53
+ try { instruments.canonicalizeOfficialNse(row.isin(), row.symbol(), row.companyName(), "NSE_OFFICIAL_ISIN_BOOTSTRAP"); }
54
+ catch (RuntimeException rejected) { log.warn("canonical_identity_bootstrap event=CANONICAL_BOOTSTRAP_FAILED reason=IDENTITY_REJECTED symbol={}", row.symbol()); }
55
+ }
56
+ store.universeLoaded(now);
57
+ }
58
+ if (store.referenceDue(now)) {
59
+ try { nifty.refresh(); store.referenceLoaded(now); }
60
+ catch (RuntimeException unavailable) { log.warn("canonical_identity_bootstrap event=PROVIDER_TEMPORARILY_UNAVAILABLE provider=NIFTY_REFERENCE"); }
61
+ }
62
+ store.enqueue();
63
+ List<UUID> ids = store.dueMappings(Math.max(1, Math.min(batchSize, 40)), Instant.now());
64
+ List<Callable<GlobalInstrumentReconciliationService.Outcome>> tasks = ids.stream()
65
+ .<Callable<GlobalInstrumentReconciliationService.Outcome>>map(id -> () -> reconcile(id)).toList();
66
+ var results = workers.invokeAll(tasks, 160, TimeUnit.SECONDS);
67
+ boolean allUnavailable = !results.isEmpty();
68
+ for (int i=0; i<results.size(); i++) {
69
+ var result = results.get(i);
70
+ GlobalInstrumentReconciliationService.Outcome outcome;
71
+ try { outcome = result.get(); }
72
+ catch (ExecutionException | CancellationException error) { outcome = new GlobalInstrumentReconciliationService.Outcome("UNAVAILABLE", "PROVIDER_TEMPORARILY_UNAVAILABLE"); }
73
+ store.completed(ids.get(i), outcome, Instant.now());
74
+ allUnavailable &= "UNAVAILABLE".equals(outcome.status());
75
+ }
76
+ if (allUnavailable) providerRetryAfter = Instant.now().plusSeconds(60);
77
+ log.info("canonical_identity_bootstrap event=BOOTSTRAP_PROGRESS counts={}", store.counts());
78
+ } catch (InterruptedException interrupted) {
79
+ Thread.currentThread().interrupt();
80
+ } catch (RuntimeException unavailable) {
81
+ providerRetryAfter = Instant.now().plusSeconds(60);
82
+ log.warn("canonical_identity_bootstrap event=CANONICAL_BOOTSTRAP_FAILED reason=PROVIDER_TEMPORARILY_UNAVAILABLE exception={}", unavailable.getClass().getSimpleName());
83
+ } finally { store.release(owner); }
84
+ }
85
+
86
+ private GlobalInstrumentReconciliationService.Outcome reconcile(UUID id) {
87
+ var result = reconciliation.reconcile(id);
88
+ String event = switch (result.status()) {
89
+ case "VALIDATED" -> "YAHOO_MAPPING_VALIDATED";
90
+ case "REJECTED" -> "NSE_MAPPING_MISSING".equals(result.reason()) ? "NSE_MAPPING_MISSING" : "YAHOO_MAPPING_REJECTED";
91
+ case "SKIPPED" -> "YAHOO_MAPPING_SKIPPED";
92
+ default -> "PROVIDER_TEMPORARILY_UNAVAILABLE";
93
+ };
94
+ log.info("canonical_identity_bootstrap event={} globalInstrumentId={} reason={}", event, id, result.reason());
95
+ return result;
96
+ }
97
+ @PreDestroy public void stop() { workers.shutdownNow(); }
98
+}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/CanonicalIdentityBootstrapStore.java
new
+60
@@ -0,0 +1,60 @@
1
+package com.aiinvestment.portfolio.application;
2
+
3
+import org.springframework.jdbc.core.JdbcTemplate;
4
+import org.springframework.stereotype.Repository;
5
+import java.sql.Timestamp;
6
+import java.time.Instant;
7
+import java.util.*;
8
+
9
+/** Durable work/lease metadata, never an alternative instrument or mapping store. */
10
+@Repository
11
+public class CanonicalIdentityBootstrapStore {
12
+ private final JdbcTemplate jdbc;
13
+ public CanonicalIdentityBootstrapStore(JdbcTemplate jdbc) { this.jdbc = jdbc; }
14
+ public boolean claim(String owner, Instant now) {
15
+ return jdbc.update("UPDATE portfolio.canonical_identity_bootstrap SET lease_owner=?, lease_until=? WHERE region='INDIA' AND lease_until<?",
16
+ owner, Timestamp.from(now.plusSeconds(300)), Timestamp.from(now)) == 1;
17
+ }
18
+ public void release(String owner) {
19
+ jdbc.update("UPDATE portfolio.canonical_identity_bootstrap SET lease_until=? WHERE region='INDIA' AND lease_owner=?", Timestamp.from(Instant.EPOCH), owner);
20
+ }
21
+ public boolean universeDue(Instant now) { return due("universe_loaded_at", now); }
22
+ public boolean referenceDue(Instant now) { return due("reference_loaded_at", now); }
23
+ private boolean due(String column, Instant now) {
24
+ Timestamp value = jdbc.queryForObject("SELECT " + column + " FROM portfolio.canonical_identity_bootstrap WHERE region='INDIA'", Timestamp.class);
25
+ return value == null || value.toInstant().isBefore(now.minusSeconds(86400));
26
+ }
27
+ public void universeLoaded(Instant now) { jdbc.update("UPDATE portfolio.canonical_identity_bootstrap SET universe_loaded_at=? WHERE region='INDIA'", Timestamp.from(now)); }
28
+ public void referenceLoaded(Instant now) { jdbc.update("UPDATE portfolio.canonical_identity_bootstrap SET reference_loaded_at=? WHERE region='INDIA'", Timestamp.from(now)); }
29
+ public void enqueue() {
30
+ jdbc.update("""
31
+ INSERT INTO portfolio.canonical_identity_mapping_jobs(instrument_id,status,next_attempt_at,updated_at)
32
+ SELECT m.instrument_id,'PENDING',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP FROM portfolio.instrument_master m
33
+ WHERE m.country='IN' AND m.primary_exchange='NSE' AND m.asset_type='EQUITY' AND m.status='ACTIVE'
34
+ AND NOT EXISTS (SELECT 1 FROM portfolio.canonical_identity_mapping_jobs j WHERE j.instrument_id=m.instrument_id)
35
+ """);
36
+ }
37
+ public List<UUID> dueMappings(int limit, Instant now) {
38
+ return jdbc.query("""
39
+ SELECT j.instrument_id FROM portfolio.canonical_identity_mapping_jobs j
40
+ LEFT JOIN portfolio.nifty500_universe u ON u.instrument_id=j.instrument_id
41
+ WHERE j.status NOT IN ('VALIDATED','SKIPPED') AND j.next_attempt_at<=?
42
+ ORDER BY CASE WHEN u.instrument_id IS NULL THEN 1 ELSE 0 END, j.next_attempt_at, j.instrument_id LIMIT ?
43
+ """, (rs,n) -> rs.getObject(1, UUID.class), Timestamp.from(now), limit);
44
+ }
45
+ public void completed(UUID id, GlobalInstrumentReconciliationService.Outcome outcome, Instant now) {
46
+ long delay = "UNAVAILABLE".equals(outcome.status()) ? 300 : 86400;
47
+ jdbc.update("UPDATE portfolio.canonical_identity_mapping_jobs SET status=?,reason=?,attempts=attempts+1,next_attempt_at=?,updated_at=? WHERE instrument_id=?",
48
+ outcome.status(), outcome.reason(), Timestamp.from(now.plusSeconds(delay)), Timestamp.from(now), id);
49
+ }
50
+ public Map<String, Object> counts() {
51
+ Map<String,Object> counts = new LinkedHashMap<>();
52
+ counts.put("canonicalUniverseLoaded", jdbc.queryForObject("SELECT count(*) FROM portfolio.instrument_master WHERE country='IN' AND primary_exchange='NSE' AND asset_type='EQUITY' AND status='ACTIVE'", Long.class));
53
+ counts.put("verifiedNseMappings", jdbc.queryForObject("SELECT count(DISTINCT instrument_id) FROM portfolio.instrument_provider_mappings WHERE provider='NSE' AND status='VERIFIED' AND resolution_source<>'BROKER_IMPORT_IDENTITY'", Long.class));
54
+ counts.put("yahooMappingsValidated", jdbc.queryForObject("SELECT count(DISTINCT instrument_id) FROM portfolio.instrument_provider_mappings WHERE provider='YAHOO_FINANCE' AND status='VERIFIED'", Long.class));
55
+ counts.put("yahooMappingsSkipped", jdbc.queryForObject("SELECT count(*) FROM portfolio.canonical_identity_mapping_jobs WHERE status='SKIPPED'", Long.class));
56
+ counts.put("yahooMappingsFailed", jdbc.queryForObject("SELECT count(*) FROM portfolio.canonical_identity_mapping_jobs WHERE status IN ('REJECTED','UNAVAILABLE')", Long.class));
57
+ counts.put("yahooMappingsPending", jdbc.queryForObject("SELECT count(*) FROM portfolio.canonical_identity_mapping_jobs WHERE status='PENDING'", Long.class));
58
+ return counts;
59
+ }
60
+}
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/GlobalInstrumentReconciliationService.java
+12
-5
@@ -23,8 +23,7 @@ public class GlobalInstrumentReconciliationService {
23
this.structuredMarket = structuredMarket;
24
}
25
26
- @Transactional
27
- public void reconcile(UUID globalInstrumentId) {
26
+ public Outcome reconcile(UUID globalInstrumentId) {
27
if (instruments.globalInstrument(globalInstrumentId).isEmpty()) throw new IllegalArgumentException("GLOBAL_INSTRUMENT_NOT_FOUND");
28
log.info("global_provider_reconciliation_start globalInstrumentId={}", globalInstrumentId);
29
var nseOutcome = nse.reconcile(globalInstrumentId);
@@ -32,22 +31,30 @@ public class GlobalInstrumentReconciliationService {
31
globalInstrumentId, nseOutcome.status(), nseOutcome.reason());
32
if (instruments.reusableMapping(globalInstrumentId, "YAHOO_FINANCE").isPresent()) {
33
log.info("global_provider_reconciliation_yahoo globalInstrumentId={} outcome=REUSED reason=VERIFIED_MAPPING_EXISTS", globalInstrumentId);
35
- return;
34
+ return new Outcome("SKIPPED", "VERIFIED_MAPPING_EXISTS");
35
}
36
+ if (instruments.mappings(globalInstrumentId).stream().anyMatch(mapping -> "YAHOO_FINANCE".equals(mapping.getProvider()) && "INVALID".equals(mapping.getStatus())))
37
+ return new Outcome("REJECTED", "INVALID_MAPPING_REQUIRES_REVIEW");
38
boolean trustedNse = instruments.mappings(globalInstrumentId).stream().anyMatch(this::trustedNse);
39
if (!trustedNse) {
40
log.info("global_provider_reconciliation_yahoo globalInstrumentId={} outcome=REJECTED reason=NO_TRUSTED_NSE_MAPPING", globalInstrumentId);
40
- return;
41
+ return new Outcome("REJECTED", "NSE_MAPPING_MISSING");
42
}
43
try {
43
- structuredMarket.fetchGlobal(globalInstrumentId);
44
+ structuredMarket.resolveGlobalIdentity(globalInstrumentId);
45
log.info("global_provider_reconciliation_yahoo globalInstrumentId={} candidateSource=VERIFIED_NSE outcome=PERSISTED reason=VALIDATED", globalInstrumentId);
46
+ return new Outcome("VALIDATED", "YAHOO_MAPPING_VALIDATED");
47
+ } catch (StructuredMarketClient.IdentityRejectedException exception) {
48
+ return new Outcome("REJECTED", exception.getMessage());
49
} catch (RuntimeException exception) {
50
log.info("global_provider_reconciliation_yahoo globalInstrumentId={} candidateSource=VERIFIED_NSE outcome=UNAVAILABLE reason={}",
51
globalInstrumentId, exception.getMessage());
52
+ return new Outcome("UNAVAILABLE", "PROVIDER_TEMPORARILY_UNAVAILABLE");
53
}
54
}
55
56
+ public record Outcome(String status, String reason) {}
57
+
58
private boolean trustedNse(InstrumentProviderMappingEntity mapping) {
59
return "NSE".equalsIgnoreCase(mapping.getProvider()) && "VERIFIED".equalsIgnoreCase(mapping.getStatus())
60
&& mapping.getProviderSymbol() != null && !mapping.getProviderSymbol().isBlank()
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/InstrumentMasterService.java
+9
-1
@@ -89,6 +89,11 @@ public class InstrumentMasterService {
89
/** Canonicalize an exact official NSE identity; never uses a name-only match. */
90
@Transactional
91
public InstrumentMasterEntity canonicalizeOfficialNse(String isin, String symbol, String companyName) {
92
+ return canonicalizeOfficialNse(isin, symbol, companyName, "OFFICIAL_NSE_NIFTY500");
93
+ }
94
+
95
+ @Transactional
96
+ public InstrumentMasterEntity canonicalizeOfficialNse(String isin, String symbol, String companyName, String source) {
97
String normalized = InstrumentMasterEntity.normalizeIsin(isin);
98
if (normalized == null || symbol == null || symbol.isBlank()) throw new IllegalArgumentException("OFFICIAL_NSE_IDENTITY_REQUIRED");
99
lockIdentity("ISIN:" + normalized);
@@ -100,7 +105,10 @@ public class InstrumentMasterService {
105
InstrumentMasterEntity master = existing.orElseGet(() -> masters.saveAndFlush(new InstrumentMasterEntity(UUID.randomUUID(), normalized,
106
firstText(companyName, symbol), com.aiinvestment.shared.domain.AssetType.EQUITY, "INR", "IN", "NSE", symbol, "ACTIVE", Instant.now())));
107
master.applyVerifiedPrimaryListing("NSE", symbol, companyName);
103
- persistMapping(master.getInstrumentId(), "NSE", symbol, normalized, "NSE", "INR", "VERIFIED", "OFFICIAL_NSE_NIFTY500", new BigDecimal("0.99"));
108
+ var verified = reusableMapping(master.getInstrumentId(), "NSE");
109
+ if (verified.isPresent() && !same(verified.get().getProviderSymbol(), symbol))
110
+ throw new IllegalStateException("NSE_IDENTITY_MISMATCH");
111
+ if (verified.isEmpty()) persistMapping(master.getInstrumentId(), "NSE", symbol, normalized, "NSE", "INR", "VERIFIED", source, new BigDecimal("0.99"));
112
return master;
113
}
114
services/portfolio-service/src/main/java/com/aiinvestment/portfolio/application/StructuredMarketClient.java
+40
@@ -76,6 +76,14 @@ public class StructuredMarketClient {
76
77
/** Resolve public structured identity directly from the global master, never a portfolio-local instrument. */
78
public Snapshot fetchGlobal(UUID globalInstrumentId) {
79
+ return fetchGlobal(globalInstrumentId, false);
80
+ }
81
+
82
+ public Snapshot resolveGlobalIdentity(UUID globalInstrumentId) {
83
+ return fetchGlobal(globalInstrumentId, true);
84
+ }
85
+
86
+ private Snapshot fetchGlobal(UUID globalInstrumentId, boolean identityOnly) {
87
var global = instrumentMaster.globalInstrument(globalInstrumentId)
88
.orElseThrow(() -> new IllegalArgumentException("GLOBAL_INSTRUMENT_NOT_FOUND"));
89
var master = global.master();
@@ -108,15 +116,47 @@ public class StructuredMarketClient {
116
payload.put("structuredProviderStatus", mapping.getStatus());
117
});
118
try {
119
+ if (identityOnly) return resolveAndPersistIdentity(globalInstrumentId, payload, master.getCurrency(), master.getPrimaryExchange());
120
return requestAndPersist(globalInstrumentId, payload, master.getCurrency(), master.getPrimaryExchange());
121
} catch (InterruptedException exception) {
122
Thread.currentThread().interrupt();
123
throw new IllegalStateException("STRUCTURED_PROVIDER_INTERRUPTED", exception);
124
} catch (Exception exception) {
125
+ if (exception instanceof IdentityRejectedException rejected) throw rejected;
126
throw new IllegalStateException("STRUCTURED_PROVIDER_UNAVAILABLE", exception);
127
}
128
}
129
130
+ private Snapshot resolveAndPersistIdentity(UUID id, Map<String, Object> payload, String currency, String exchange) throws Exception {
131
+ String candidate = (String) payload.get("structuredNseCandidateTicker");
132
+ if (candidate == null) throw new IdentityRejectedException("NSE_MAPPING_MISSING");
133
+ String identityEndpoint = endpoint.replace("/api/v1/research/structured-market/snapshot", "/internal/v1/research/instruments/resolve-provider");
134
+ HttpRequest request = HttpRequest.newBuilder(URI.create(identityEndpoint)).timeout(Duration.ofSeconds(15))
135
+ .header("Content-Type", "application/json").header("X-AIP-Service-Identity", "portfolio-service")
136
+ .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload))).build();
137
+ HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
138
+ if (response.statusCode() == 422) throw new IdentityRejectedException(mapper.readTree(response.body()).path("detail").asText("IDENTITY_REJECTED"));
139
+ if (response.statusCode() != 200) throw new IllegalStateException("PROVIDER_TEMPORARILY_UNAVAILABLE");
140
+ JsonNode raw = mapper.readTree(response.body());
141
+ String ticker = text(raw, "provider_ticker", "providerTicker");
142
+ String returnedId = text(raw, "instrument_id", "instrumentId");
143
+ if (!id.toString().equals(returnedId) || !candidate.equalsIgnoreCase(ticker)
144
+ || !"VERIFIED_NSE_CANDIDATE".equals(raw.path("status").asText()))
145
+ throw new IdentityRejectedException("PROVIDER_IDENTITY_MISMATCH");
146
+ // Yahoo search may omit currency. An exact validated NSE listing retains its official INR currency.
147
+ Snapshot identity = new Snapshot(ticker, text(raw,"exchange","exchange"), text(raw,"currency","currency",currency),
148
+ text(raw,"quote_type","quoteType"), null, null, null, Instant.now(), "YAHOO_FROM_VERIFIED_NSE", "VERIFIED");
149
+ try { validateIdentity(currency, exchange, identity); }
150
+ catch (IllegalArgumentException error) { throw new IdentityRejectedException(error.getMessage()); }
151
+ instrumentMaster.saveResolvedMapping(id, "YAHOO_FINANCE", ticker, null, exchange, currency,
152
+ "VERIFIED", "YAHOO_FROM_VERIFIED_NSE", new BigDecimal("0.95"));
153
+ return identity;
154
+ }
155
+
156
+ public static class IdentityRejectedException extends RuntimeException {
157
+ public IdentityRejectedException(String reason) { super(reason); }
158
+ }
159
+
160
private Snapshot requestAndPersist(UUID masterId, Map<String, Object> payload, String expectedCurrency,
161
String expectedExchange) throws Exception {
162
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(15))
services/portfolio-service/src/main/resources/db/migration/V21__canonical_identity_bootstrap.sql
new
+17
@@ -0,0 +1,17 @@
1
+CREATE TABLE canonical_identity_bootstrap (
2
+ region VARCHAR(16) PRIMARY KEY,
3
+ lease_owner VARCHAR(64),
4
+ lease_until TIMESTAMP WITH TIME ZONE NOT NULL,
5
+ universe_loaded_at TIMESTAMP WITH TIME ZONE,
6
+ reference_loaded_at TIMESTAMP WITH TIME ZONE
7
+);
8
+INSERT INTO canonical_identity_bootstrap(region, lease_until) VALUES ('INDIA', TIMESTAMP '1970-01-01 00:00:00');
9
+CREATE TABLE canonical_identity_mapping_jobs (
10
+ instrument_id UUID PRIMARY KEY REFERENCES instrument_master(instrument_id),
11
+ status VARCHAR(32) NOT NULL,
12
+ reason VARCHAR(200),
13
+ attempts INTEGER NOT NULL DEFAULT 0,
14
+ next_attempt_at TIMESTAMP WITH TIME ZONE NOT NULL,
15
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL
16
+);
17
+CREATE INDEX idx_identity_mapping_due ON canonical_identity_mapping_jobs(status, next_attempt_at);
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/CanonicalIdentityBootstrapFailureTest.java
new
+40
@@ -0,0 +1,40 @@
1
+package com.aiinvestment.portfolio.application;
2
+
3
+import org.junit.jupiter.api.Test;
4
+import java.util.List;
5
+import static org.mockito.Mockito.*;
6
+
7
+class CanonicalIdentityBootstrapFailureTest {
8
+ @Test void temporaryOfficialOutageDoesNotMarkUniverseLoadedOrCreateMappings() {
9
+ var official=mock(NseOfficialSecurityMaster.class);
10
+ var nifty=mock(Nifty500ReferenceService.class);
11
+ var instruments=mock(InstrumentMasterService.class);
12
+ var reconciliation=mock(GlobalInstrumentReconciliationService.class);
13
+ var store=mock(CanonicalIdentityBootstrapStore.class);
14
+ when(store.claim(any(),any())).thenReturn(true);
15
+ when(store.universeDue(any())).thenReturn(true);
16
+ when(official.listedEquities()).thenReturn(List.of());
17
+ var bootstrap=new CanonicalIdentityBootstrap(official,nifty,instruments,reconciliation,store);
18
+ try {bootstrap.tick();bootstrap.tick();} finally {bootstrap.stop();}
19
+ verify(official,times(1)).listedEquities(); // bounded retry cooldown
20
+ verify(store,never()).universeLoaded(any());
21
+ verifyNoInteractions(instruments,reconciliation,nifty);
22
+ verify(store).release(any());
23
+ }
24
+
25
+ @Test void ambiguousOfficialRowsNeverReachCanonicalizationOrYahoo() {
26
+ var official=mock(NseOfficialSecurityMaster.class);
27
+ var nifty=mock(Nifty500ReferenceService.class);
28
+ var instruments=mock(InstrumentMasterService.class);
29
+ var reconciliation=mock(GlobalInstrumentReconciliationService.class);
30
+ var store=mock(CanonicalIdentityBootstrapStore.class);
31
+ when(store.claim(any(),any())).thenReturn(true);
32
+ when(store.universeDue(any())).thenReturn(true);
33
+ when(official.listedEquities()).thenReturn(List.of(
34
+ new NseOfficialSecurityMaster.Listing("ALPHA","INE111A01010","Alpha Limited","EQ"),
35
+ new NseOfficialSecurityMaster.Listing("BETA","INE111A01010","Beta Limited","EQ")));
36
+ var bootstrap=new CanonicalIdentityBootstrap(official,nifty,instruments,reconciliation,store);
37
+ try {bootstrap.tick();} finally {bootstrap.stop();}
38
+ verifyNoInteractions(instruments,reconciliation);
39
+ }
40
+}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/CanonicalIdentityBootstrapTest.java
new
+76
@@ -0,0 +1,76 @@
1
+package com.aiinvestment.portfolio.application;
2
+
3
+import com.aiinvestment.portfolio.infrastructure.persistence.*;
4
+import org.junit.jupiter.api.Test;
5
+import org.springframework.beans.factory.annotation.Autowired;
6
+import org.springframework.boot.test.context.SpringBootTest;
7
+import org.springframework.boot.test.mock.mockito.MockBean;
8
+import org.springframework.test.context.ActiveProfiles;
9
+import java.math.BigDecimal;
10
+import java.time.Instant;
11
+import java.util.*;
12
+import static org.assertj.core.api.Assertions.*;
13
+import static org.mockito.Mockito.*;
14
+
15
+@SpringBootTest
16
+@ActiveProfiles("test")
17
+class CanonicalIdentityBootstrapTest {
18
+ @Autowired InstrumentMasterService instruments;
19
+ @Autowired InstrumentMasterRepository masters;
20
+ @Autowired InstrumentProviderMappingRepository mappings;
21
+ @Autowired PortfolioPositionRepository positions;
22
+ @Autowired CanonicalIdentityBootstrapStore store;
23
+ @Autowired GlobalInstrumentReconciliationService reconciliation;
24
+ @MockBean NseOfficialSecurityMaster official;
25
+ @MockBean Nifty500ReferenceService nifty;
26
+ @MockBean StructuredMarketClient structured;
27
+
28
+ @Test void emptyDatabaseBootstrapPersistsOnlyIdentitiesAndIsRestartSafe() {
29
+ assertThat(masters.count()).isZero();
30
+ var rows = List.of(new NseOfficialSecurityMaster.Listing("ALPHA", "INE111A01010", "Alpha Components Limited", "EQ"),
31
+ new NseOfficialSecurityMaster.Listing("BETA", "INE222A01010", "Beta Engineering Limited", "EQ"),
32
+ new NseOfficialSecurityMaster.Listing("GAMMA", "INE333A01010", "Gamma Services Limited", "EQ"));
33
+ when(official.listedEquities()).thenReturn(rows);
34
+ when(structured.resolveGlobalIdentity(any())).thenAnswer(call -> {
35
+ UUID id = call.getArgument(0);
36
+ var master = instruments.globalInstrument(id).orElseThrow().master();
37
+ instruments.saveResolvedMapping(id,"YAHOO_FINANCE",master.getPrimarySymbol()+".NS",null,"NSE","INR","VERIFIED","YAHOO_FROM_VERIFIED_NSE",new BigDecimal("0.95"));
38
+ return null;
39
+ });
40
+ CanonicalIdentityBootstrap bootstrap = new CanonicalIdentityBootstrap(official,nifty,instruments,reconciliation,store);
41
+ try { bootstrap.tick(); } finally { bootstrap.stop(); }
42
+ assertThat(masters.count()).isEqualTo(3);
43
+ assertThat(mappings.count()).isEqualTo(6);
44
+ assertThat(positions.count()).isZero();
45
+ Map<UUID,Instant> verified = new HashMap<>();
46
+ mappings.findAll().forEach(m -> verified.put(m.getMappingId(),m.getVerifiedAt()));
47
+ CanonicalIdentityBootstrap restarted = new CanonicalIdentityBootstrap(official,nifty,instruments,reconciliation,store);
48
+ try { restarted.tick(); } finally { restarted.stop(); }
49
+ assertThat(mappings.count()).isEqualTo(6);
50
+ mappings.findAll().forEach(m -> assertThat(m.getVerifiedAt()).isEqualTo(verified.get(m.getMappingId())));
51
+ verify(official,times(1)).listedEquities();
52
+ verify(structured,times(3)).resolveGlobalIdentity(any());
53
+ verify(structured,never()).fetchGlobal(any());
54
+ verify(structured,never()).fetch(any());
55
+ assertThat(store.dueMappings(40,Instant.now())).isEmpty();
56
+ for (var master : masters.findAll()) {
57
+ instruments.canonicalizeOfficialNse(master.getIsin(),master.getPrimarySymbol(),master.getCanonicalName());
58
+ }
59
+ mappings.findAll().forEach(m -> assertThat(m.getVerifiedAt()).isEqualTo(verified.get(m.getMappingId())));
60
+
61
+ var failed = instruments.canonicalizeOfficialNse("INE444A01010","DELTA","Delta Manufacturing Limited");
62
+ doThrow(new IllegalStateException("PROVIDER_TEMPORARILY_UNAVAILABLE")).when(structured).resolveGlobalIdentity(failed.getInstrumentId());
63
+ var unavailable = reconciliation.reconcile(failed.getInstrumentId());
64
+ assertThat(unavailable.status()).isEqualTo("UNAVAILABLE");
65
+ assertThat(instruments.reusableMapping(failed.getInstrumentId(),"YAHOO_FINANCE")).isEmpty();
66
+ assertThat(instruments.reusableMapping(failed.getInstrumentId(),"NSE")).isPresent();
67
+ instruments.recordMappingFailure(failed.getInstrumentId(),"YAHOO_FINANCE","WRONG.NS",null,"NSE","INR","TEST_REJECTION",BigDecimal.ZERO,"ISIN_MISMATCH");
68
+ clearInvocations(structured);
69
+ assertThat(reconciliation.reconcile(failed.getInstrumentId()).status()).isEqualTo("REJECTED");
70
+ assertThat(instruments.mappings(failed.getInstrumentId())).filteredOn(m -> m.getProvider().equals("YAHOO_FINANCE"))
71
+ .allMatch(m -> m.getStatus().equals("INVALID"));
72
+ verifyNoInteractions(structured);
73
+ assertThatThrownBy(() -> instruments.canonicalizeOfficialNse("INE555A01010","DELTA","Different Company"))
74
+ .isInstanceOf(IllegalStateException.class).hasMessageContaining("NSE_IDENTITY_MISMATCH");
75
+ }
76
+}
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/GlobalInstrumentReconciliationServiceTest.java
+3
-3
@@ -40,7 +40,7 @@ class GlobalInstrumentReconciliationServiceTest {
40
service.reconcile(id);
41
42
verify(nse).reconcile(id);
43
- verify(structured).fetchGlobal(id);
43
+ verify(structured).resolveGlobalIdentity(id);
44
}
45
46
@Test
@@ -66,11 +66,11 @@ class GlobalInstrumentReconciliationServiceTest {
66
67
@Test
68
void structuredProviderFailureIsContainedForFutureRetry() {
69
- doThrow(new IllegalStateException("STRUCTURED_PROVIDER_UNAVAILABLE")).when(structured).fetchGlobal(id);
69
+ doThrow(new IllegalStateException("STRUCTURED_PROVIDER_UNAVAILABLE")).when(structured).resolveGlobalIdentity(id);
70
71
service.reconcile(id);
72
73
- verify(structured).fetchGlobal(id);
73
+ verify(structured).resolveGlobalIdentity(id);
74
verify(instruments, never()).saveResolvedMapping(any(), any(), any(), any(), any(), any(), any(), any(), any());
75
}
76
services/portfolio-service/src/test/java/com/aiinvestment/portfolio/application/ProviderIdentityClientTest.java
new
+51
@@ -0,0 +1,51 @@
1
+package com.aiinvestment.portfolio.application;
2
+
3
+import com.aiinvestment.portfolio.infrastructure.persistence.*;
4
+import com.aiinvestment.shared.domain.AssetType;
5
+import com.fasterxml.jackson.databind.ObjectMapper;
6
+import com.sun.net.httpserver.HttpServer;
7
+import org.junit.jupiter.params.ParameterizedTest;
8
+import org.junit.jupiter.params.provider.ValueSource;
9
+import java.net.InetSocketAddress;
10
+import java.nio.charset.StandardCharsets;
11
+import java.time.Instant;
12
+import java.math.BigDecimal;
13
+import java.util.*;
14
+import static org.assertj.core.api.Assertions.*;
15
+import static org.mockito.Mockito.*;
16
+
17
+class ProviderIdentityClientTest {
18
+ @ParameterizedTest @ValueSource(ints={200,422,503})
19
+ void identityOnlyWireContractNeverFetchesResearchAndPersistsOnlyValidatedResponses(int status) throws Exception {
20
+ UUID id=UUID.randomUUID();
21
+ var instruments=mock(InstrumentMasterService.class);
22
+ var nse=mock(NseMappingReconciliationService.class);
23
+ var master=new InstrumentMasterEntity(id,"INE111A01010","Alpha Components Limited",AssetType.EQUITY,"INR","IN","NSE","ALPHA","ACTIVE",Instant.now());
24
+ var mapping=new InstrumentProviderMappingEntity(UUID.randomUUID(),id,"NSE","ALPHA","INE111A01010","NSE","INR","VERIFIED","NSE_OFFICIAL_ISIN_BOOTSTRAP",new BigDecimal(".99"),Instant.now());
25
+ when(instruments.globalInstrument(id)).thenReturn(Optional.of(new InstrumentMasterService.GlobalInstrument(master,List.of(mapping))));
26
+ List<String> paths=new ArrayList<>();
27
+ HttpServer server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);
28
+ server.createContext("/", exchange -> {
29
+ paths.add(exchange.getRequestURI().getPath());
30
+ assertThat(exchange.getRequestHeaders().getFirst("X-AIP-Service-Identity")).isEqualTo("portfolio-service");
31
+ String body=new String(exchange.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);
32
+ assertThat(body).contains("VERIFIED_NSE","ALPHA.NS","INE111A01010");
33
+ String response=status==200?"{\"instrument_id\":\""+id+"\",\"provider_ticker\":\"ALPHA.NS\",\"exchange\":\"NSI\",\"quote_type\":\"EQUITY\",\"status\":\"VERIFIED_NSE_CANDIDATE\"}":"{\"detail\":\"ISIN_MISMATCH\"}";
34
+ byte[] bytes=response.getBytes(StandardCharsets.UTF_8);exchange.sendResponseHeaders(status,bytes.length);
35
+ exchange.getResponseBody().write(bytes);exchange.close();
36
+ });
37
+ server.start();
38
+ try {
39
+ var client=new StructuredMarketClient(new ObjectMapper(),instruments,nse,"http://127.0.0.1:"+server.getAddress().getPort());
40
+ if(status==200) {
41
+ assertThat(client.resolveGlobalIdentity(id).providerTicker()).isEqualTo("ALPHA.NS");
42
+ verify(instruments).saveResolvedMapping(eq(id),eq("YAHOO_FINANCE"),eq("ALPHA.NS"),isNull(),eq("NSE"),eq("INR"),eq("VERIFIED"),eq("YAHOO_FROM_VERIFIED_NSE"),eq(new BigDecimal(".95")));
43
+ } else {
44
+ assertThatThrownBy(()->client.resolveGlobalIdentity(id)).isInstanceOf(RuntimeException.class);
45
+ verify(instruments,never()).saveResolvedMapping(any(),any(),any(),any(),any(),any(),any(),any(),any());
46
+ }
47
+ assertThat(paths).containsExactly("/internal/v1/research/instruments/resolve-provider");
48
+ verifyNoInteractions(nse);
49
+ } finally {server.stop(0);}
50
+ }
51
+}
services/portfolio-service/src/test/resources/application-test.yml
+2
@@ -16,4 +16,6 @@ springdoc:
16
swagger-ui:
17
enabled: false
18
market:
19
+ identity-bootstrap:
20
+ enabled: false
21
demo-mode: true