Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import com.idea2strategy.backend.application.competition.OwnedRoomManagementQueryService;
import com.idea2strategy.backend.application.competition.OwnedRoomManagementView;
import java.util.List;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -24,5 +27,10 @@ public Response list(@RequestParam(defaultValue = "50") int limit) {
return new Response(service.list(limit));
}

@GetMapping("/{roomId}")
public ResponseEntity<OwnedRoomManagementView> get(@PathVariable UUID roomId) {
return ResponseEntity.of(service.get(roomId));
}

public record Response(List<OwnedRoomManagementView> items) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.idea2strategy.backend.application.competition.OwnedRoomManagementQueryService;
import com.idea2strategy.backend.application.competition.OwnedRoomManagementQueryPort;
import com.idea2strategy.backend.application.competition.OwnedRoomManagementView;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
Expand All @@ -28,9 +30,14 @@ void returnsOwnerConfigurationInvitationAndParticipationEvidence() throws Except
NOW.plusSeconds(1200), NOW.plusSeconds(1800), "Asia/Seoul",
List.of(new OwnedRoomManagementView.Invitation(id(6), "LINK", NOW, NOW.plusSeconds(600), null, null)),
List.of(new OwnedRoomManagementView.Participation(id(7), id(8), "Bot A", "ACTIVE", NOW)));
var service = new OwnedRoomManagementQueryService((owner, limit) -> {
if (!owner.equals(ACCOUNT_ID) || limit != 25) throw new AssertionError("principal or limit lost");
return List.of(view);
var service = new OwnedRoomManagementQueryService(new OwnedRoomManagementQueryPort() {
@Override public List<OwnedRoomManagementView> findOwnedBy(UUID owner, int limit) {
if (!owner.equals(ACCOUNT_ID) || limit != 25) throw new AssertionError("principal or limit lost");
return List.of(view);
}
@Override public Optional<OwnedRoomManagementView> findOwnedById(UUID owner, UUID roomId) {
return owner.equals(ACCOUNT_ID) && roomId.equals(ROOM_ID) ? Optional.of(view) : Optional.empty();
}
}, () -> ACCOUNT_ID);
var mvc = MockMvcBuilders.standaloneSetup(new OwnedRoomManagementController(service)).build();

Expand All @@ -40,6 +47,11 @@ void returnsOwnerConfigurationInvitationAndParticipationEvidence() throws Except
.andExpect(jsonPath("$.items[0].accessType").value("SECRET"))
.andExpect(jsonPath("$.items[0].invitations[0].credentialType").value("LINK"))
.andExpect(jsonPath("$.items[0].participations[0].anonymousAlias").value("Bot A"));
mvc.perform(get("/api/v1/competition/rooms/mine/{roomId}", ROOM_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.roomId").value(ROOM_ID.toString()));
mvc.perform(get("/api/v1/competition/rooms/mine/{roomId}", id(99)))
.andExpect(status().isNotFound());
}

private static UUID id(int suffix) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
package com.idea2strategy.backend.api.journey;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
Expand All @@ -30,17 +33,18 @@
* runs the line end to end over HTTP: sign up, log in, create a strategy, delegate editing of it,
* reach the edit service under that delegation, and lose access the moment it is revoked.
*
* <p>It stops short of applying blocks. A new strategy has no groups and the delegated operations
* cannot create one, so a real apply needs a valid Basic skeleton with a catalog and instruments;
* that belongs in a strategy-authoring fixture rather than here. What this test does establish is
* the part that was actually broken — that the routes exist and that a granted delegation carries
* a request through authorization, which no stub could show.
* <p>It stops short of applying blocks. The delegated operation creates a group, but a real apply
* also needs a complete valid chain; that belongs in a strategy-authoring fixture rather than here.
* What this test establishes is the part that was actually broken — that the routes exist and that
* a granted delegation carries a request through authorization, which no stub could show.
*/
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
class ExternalToolDelegatedEditJourneyIntegrationTest {
private static final String EMAIL = "delegated-edit@example.com";
private static final String PASSWORD = "CorrectHorse!2026";
private static final UUID INSTRUMENT_ID = UUID.fromString("11111111-1111-4111-8111-111111111111");
private static final UUID SYMBOL_ID = UUID.fromString("22222222-2222-4222-8222-222222222222");

@Container
static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine");
Expand All @@ -65,10 +69,22 @@ static void properties(DynamicPropertyRegistry registry) {

@Autowired WebApplicationContext context;
@Autowired ObjectMapper json;
@Autowired JdbcTemplate jdbc;

@Test
void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception {
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
jdbc.update("""
insert into market_data.instruments
(id, asset_type, primary_exchange_mic, currency_code, provider_reference, listed_at, created_at)
values (?::uuid, 'STOCK'::market_data.asset_type, 'XNAS', 'USD', 'delegated-edit-e2e',
date '2000-01-01', now())
""", INSTRUMENT_ID.toString());
jdbc.update("""
insert into market_data.instrument_symbols
(id, instrument_id, exchange_mic, symbol, effective_from)
values (?::uuid, ?::uuid, 'XNAS', 'AAPL', timestamp with time zone '2000-01-01 00:00:00+00')
""", SYMBOL_ID.toString(), INSTRUMENT_ID.toString());

JsonNode signup = json.readTree(mvc.perform(post("/api/v1/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
Expand Down Expand Up @@ -108,13 +124,20 @@ void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception
// Returned exactly once. Nothing later in the journey can recover it.
assertThat(grant.path("credential").asText()).isNotBlank();

JsonNode instruments = json.readTree(mvc.perform(get("/api/v1/strategy-catalogs/basic/instruments")
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString());
String instrumentId = instruments.path("instruments").path(0).path("id").asText();
assertThat(instrumentId).isNotBlank();

String editBody = """
{"authorizationId":"%s","credentialId":"%s","operations":[
{"action":"ADD_GROUP","arguments":{"groupId":"buy","container":"BUY",
"evaluationMode":"INDEPENDENT","allocationMode":"EQUAL",
"instrumentIds":["11111111-1111-4111-8111-111111111111"]}}]}
"instrumentIds":["%s"]}}]}
""".formatted(
grant.path("authorizationId").asText(), grant.path("credentialId").asText());
grant.path("authorizationId").asText(), grant.path("credentialId").asText(), instrumentId);

// The strategy is untouched — {"groups":[],"mode":"BASIC"} — and the tool builds its
// container anyway. Before this work the same call answered 404 because the route did not
Expand Down
27 changes: 25 additions & 2 deletions apps/idea2strategy-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ delegation create --name NAME --scopes STRATEGY_EDIT,STRATEGY_VALIDATE --strateg
[--expires-at ISO_8601_INSTANT]
delegation revoke --authorization-id ID
strategy list [--limit 1..100] [--cursor CURSOR]
strategy get --strategy-id ID
strategy create --name NAME [--description TEXT]
strategy copy --strategy-id ID --name NAME
strategy delete --strategy-id ID --yes
strategy edit preview --strategy-id ID --authorization-id ID --credential-id ID --operations-file FILE
strategy edit apply --strategy-id ID --authorization-id ID --credential-id ID --operations-file FILE --preview-hash HASH
--expected-edit-sequence SEQUENCE
Expand All @@ -50,12 +52,33 @@ strategy release --strategy-id ID --validation-run-id ID --initial-cash-amount A
--broker-rules-version VERSION --accounting-rules-version VERSION --precision-rules-version VERSION
--fee-policy-id ID --buying-power-buffer-policy-id ID --dataset-manifest-id ID
--execution-policy-version VERSION --candidate-conflict-policy JSON_OBJECT
bot list
bot get --bot-id ID
bot stop --bot-id ID [--reason-code USER_REQUEST] --yes
backtest create --bot-id ID --period-start YYYY-MM-DD --period-end YYYY-MM-DD
backtest list [--limit 1..200] [--offset 0..]
backtest get --run-id ID
backtest cancel --run-id ID [--reason-code USER_CANCELLED] --yes
backtest delete --run-id ID --yes
competition create --input-file FILE
competition list [--scope mine|public] [--limit 1..100]
competition get --room-id ID
competition delete --room-id ID [--reason-code USER_CANCELLED] --yes
operator bootstrap --manifest REVIEWED.json --expected-sha256 LOWERCASE_SHA256
```

A delegated tool can build a strategy from nothing: `ADD_GROUP` creates a trade container, naming
its side, how its blocks combine, how capital is split, and which instruments it trades. A strategy
holds one container per side, so a second container on a side already in use is refused.
its side, how its blocks combine, how capital is split, and which instruments it trades. Multiple
independent containers may use the same side, up to the Basic composition limit; each must have a
unique group id.
`SET_GROUP_INSTRUMENTS` replaces one container's complete official-instrument set, which keeps CLI,
backend validation, and the visual editor on the same persisted document format.

Bots are immutable after creation. The CLI intentionally exposes only read and safe stop operations;
there is no bot update command. Backtest deletion is evidence-preserving soft deletion: queued work is
cancelled, running work is asked to stop cooperatively, and retained execution evidence is hidden from
the owner's normal reads only after it is terminal. Competition rooms expose create/read/cancel only;
`competition delete` maps to the domain cancellation workflow and never physically erases audit history.

A delegation must name the strategies it may edit; one that names none would be granted and then
authorize nothing. `--expires-at` is optional and defaults to 24 hours from the grant. The raw
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;

final class ApiClient {
private static final ObjectMapper JSON = new ObjectMapper();
Expand All @@ -32,14 +33,22 @@ JsonNode get(String path, String token) {
}

JsonNode post(String path, JsonNode body, String token) {
return send("POST", path, body, token);
return send("POST", path, body, token, Map.of());
}

JsonNode post(String path, JsonNode body, String token, Map<String, String> headers) {
return send("POST", path, body, token, headers);
}

JsonNode delete(String path, String token) {
return send("DELETE", path, null, token);
return send("DELETE", path, null, token, Map.of());
}

private JsonNode send(String method, String path, JsonNode body, String token) {
return send(method, path, body, token, Map.of());
}

private JsonNode send(String method, String path, JsonNode body, String token, Map<String, String> headers) {
HttpRequest.Builder request = HttpRequest.newBuilder(baseUri.resolve(path))
.timeout(Duration.ofSeconds(30))
.header("Accept", "application/json")
Expand All @@ -50,6 +59,7 @@ private JsonNode send(String method, String path, JsonNode body, String token) {
if (body != null) {
request.header("Content-Type", "application/json");
}
headers.forEach(request::header);
String encoded = body == null ? "" : body.toString();
switch (method) {
case "GET" -> request.GET();
Expand Down Expand Up @@ -99,7 +109,12 @@ private static CliFailure httpFailure(int status, JsonNode body) {
default -> status >= 500 ? "SERVICE_ERROR" : "REQUEST_REJECTED";
};
String code = body.path("code").asText(fallbackCode);
String message = body.path("message").asText("Idea2Strategy API rejected the request");
String message = body.path("message").asText();
if (message.isBlank()) {
message = body.path("detail").isTextual()
? body.path("detail").asText()
: body.path("title").asText("Idea2Strategy API rejected the request");
}
return new CliFailure(exitCode, code, message, status);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class Arguments {
* turn a forgotten value — `--email` with nothing after it — into the string "true" and let a
* typo log in as nobody.
*/
private static final List<String> VALUELESS = List.of("--browser", "--no-open");
private static final List<String> VALUELESS = List.of("--browser", "--no-open", "--yes");

private final List<String> positionals;
private final Map<String, String> options;
Expand Down
Loading