From 755d2265054e5140e82d322681566c3034583c95 Mon Sep 17 00:00:00 2001 From: Kostya Date: Tue, 18 Aug 2026 18:19:50 +0300 Subject: Break down purchase receipts by cost/commission, harden XML validation and state I/O Adds a full pre-load validation pass for GM XML seeds (ids, names, options, commission, LMSR liquidity), replaces IO.print with System.out, and surfaces detailed error messages across load/save commands instead of generic failures. --- .../guess/service/LocalGuessMarketContext.java | 20 +++++- .../catalog/infrastructure/MarketContext.java | 18 +++-- .../infrastructure/mapper/v1/EventMapperV1.java | 17 +++-- .../infrastructure/provider/v1/XMLLoaderV1.java | 34 +++++++-- .../infrastructure/provider/v1/XMLValidatorV1.java | 80 ++++++++++++++++++++-- 5 files changed, 142 insertions(+), 27 deletions(-) (limited to 'service/src/main/java') diff --git a/service/src/main/java/market/guess/service/LocalGuessMarketContext.java b/service/src/main/java/market/guess/service/LocalGuessMarketContext.java index 909a808..f04324b 100644 --- a/service/src/main/java/market/guess/service/LocalGuessMarketContext.java +++ b/service/src/main/java/market/guess/service/LocalGuessMarketContext.java @@ -10,6 +10,7 @@ import market.guess.model.event.EventDetailDTO; import market.guess.service.catalog.infrastructure.MarketContext; import market.guess.service.domain.Order; import market.guess.service.fulfillment.FulfillmentContext; +import market.guess.service.helpers.BigDecimalOptions; import market.guess.service.helpers.InstantOptions; import market.guess.service.matching.MatchingEngine; import market.guess.service.risk.RiskEngine; @@ -55,6 +56,20 @@ public final class LocalGuessMarketContext implements GuessMarketContext { var rawTrades = matching.match(order); var trades = fulfillment.fulfill(user, order, rawTrades); + + var totalSharesCost = + trades.stream() + .map(t -> t.sharesCost()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + var totalCommission = + trades.stream() + .map(t -> t.commission()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + var totalPaid = + trades.stream() + .map(t -> t.totalPaid()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + return Result.ok( new PurchaseReceiptDTO( trades.stream() @@ -65,8 +80,11 @@ public final class LocalGuessMarketContext implements GuessMarketContext { t.buyerUserName(), t.marketName(), String.valueOf(t.quantity()), - t.totalPaid().toPlainString())) + t.sharesCost().toPlainString())) .toList(), + totalSharesCost.toPlainString(), + totalCommission.toPlainString(), + totalPaid.toPlainString(), event.toMarketState())); } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java b/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java index cb2b467..5fc2e24 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java @@ -67,15 +67,22 @@ public final class MarketContext { GSON.toJson(new Wrapper(events.getAll(), users.getAll())), StandardCharsets.UTF_8); } catch (Exception e) { - throw new GuessMarketException("Failed to sove the file."); + throw new GuessMarketException("Failed to save state to " + target + ": " + e.getMessage()); } } public void load(Path path) throws GuessMarketException { + var target = withJsonExtension(path); + if (!Files.isRegularFile(target)) { + throw new GuessMarketException("State file not found: " + target); + } try { var wrapper = GSON.fromJson( - Files.readString(withJsonExtension(path), StandardCharsets.UTF_8), Wrapper.class); + Files.readString(target, StandardCharsets.UTF_8), Wrapper.class); + if (wrapper == null || wrapper.events() == null) { + throw new GuessMarketException("State file contains invalid or empty data."); + } events.clear(); users.clear(); @@ -88,14 +95,15 @@ public final class MarketContext { } } catch (JsonSyntaxException e) { - throw new GuessMarketException("The JSON file is malformed."); + throw new GuessMarketException("The state file is malformed JSON: " + e.getMessage()); } catch (IOException e) { - throw new GuessMarketException("Unable the file."); + throw new GuessMarketException("Unable to read state file: " + e.getMessage()); } } private static Path withJsonExtension(Path path) { + if (path == null) return null; var name = path.getFileName().toString(); - return name.endsWith(".json") ? path : path.resolveSibling(name + ".json"); + return name.toLowerCase().endsWith(".json") ? path : path.resolveSibling(name + ".json"); } } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java index f45eb46..b9c43bb 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java @@ -19,8 +19,8 @@ public final class EventMapperV1 implements Mapper { return new Event( getEventKey(source), source.getId(), - joinName(source.getName()), - source.getDescription(), + joinName(source.getName()).trim(), + source.getDescription() != null ? source.getDescription().trim() : "", source.getComision().getValue(), getTiming(source.getComision().getType()), new LmsrTradingMechanism(source.getGMMethod().getGMLMSR().getB(), options.size()), @@ -38,17 +38,20 @@ public final class EventMapperV1 implements Mapper { } private static CommissionTiming getTiming(String type) throws IllegalArgumentException { - return switch (type) { + if (type == null) throw new IllegalArgumentException("Commission type cannot be null"); + return switch (type.toLowerCase().trim()) { case "on-close" -> CommissionTiming.ON_CLOSE; case "on-purchase" -> CommissionTiming.ON_PURCHASE; - default -> throw new IllegalArgumentException("Unknown comision type: " + type); + default -> throw new IllegalArgumentException("Unknown commission type: " + type); }; } private static List getOptions(GMEvent x) { - var ret = new ArrayList(x.getGMOptions().getGMOption().size()); - for (var i = 0; i < x.getGMOptions().getGMOption().size(); i++) { - ret.add(new Market(getEventKey(x) + ":" + i, x.getGMOptions().getGMOption().get(i))); + var raw = x.getGMOptions().getGMOption(); + var ret = new ArrayList(raw.size()); + for (var i = 0; i < raw.size(); i++) { + var opt = raw.get(i); + ret.add(new Market(getEventKey(x) + ":" + i, opt != null ? opt.trim() : "")); } return ret; } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java index bcae787..44e1c7f 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java @@ -2,8 +2,10 @@ package market.guess.service.catalog.infrastructure.provider.v1; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; +import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import market.guess.api.CatalogContext; import market.guess.api.LoadResult; import market.guess.api.Result; @@ -12,6 +14,7 @@ import market.guess.service.catalog.infrastructure.provider.Loader; import market.guess.service.catalog.infrastructure.repository.EventRepository; import market.guess.service.catalog.infrastructure.repository.UserRepository; import market.guess.service.catalog.model.v1.GuessMarket; +import market.guess.service.domain.Event; import market.guess.service.domain.User; public final class XMLLoaderV1 implements Loader { @@ -35,19 +38,23 @@ public final class XMLLoaderV1 implements Loader { try { context = JAXBContext.newInstance(GuessMarket.class); } catch (JAXBException e) { - throw new IllegalStateException(); + throw new IllegalStateException("Failed to initialize JAXB context: " + e.getMessage(), e); } } @Override public Result load(Path path) { + if (path == null) { + return Result.error("INVALID_PATH", "File path cannot be null."); + } + if (!Files.isRegularFile(path)) { - return Result.error("FILE_NOT_FOUND", "No such file."); + return Result.error("FILE_NOT_FOUND", "No such file: " + path); } if (path.getFileName() == null || !path.getFileName().toString().toLowerCase().endsWith(".xml")) { - return Result.error("NOT_XML", "The file must be an XML."); + return Result.error("NOT_XML", "The file must have a .xml extension."); } try (var stream = Files.newInputStream(path)) { @@ -59,21 +66,34 @@ public final class XMLLoaderV1 implements Loader { return Result.error(validation.getMessage(), validation.getDetails()); } + var domainEvents = new ArrayList(); for (var event : seed.getGMEvents().getGMEvent()) { - eventRepository.add(mapper.toDomain(event)); + domainEvents.add(mapper.toDomain(event)); + } + + eventRepository.clear(); + userRepository.clear(); + + for (var domainEvent : domainEvents) { + eventRepository.add(domainEvent); } - userRepository.add(new User("Tester", 500)); + userRepository.add(new User("Tester", new BigDecimal("500.00"))); return Result.ok(new LoadResult(path.toString(), eventRepository.getAll().size())); + } catch (JAXBException e) { + var cause = + e.getLinkedException() != null ? e.getLinkedException().getMessage() : e.getMessage(); + return Result.error( + "XML_PARSE_ERROR", + "The XML file is malformed or invalid: " + (cause != null ? cause : e.getMessage())); } catch (Exception e) { - return Result.error("UNREADABLE", "The file could not be read."); + return Result.error("UNREADABLE", "The file could not be read: " + e.getMessage()); } } @Override public Result seed(CatalogContext context) { - // TODO Auto-generated method stub throw new UnsupportedOperationException("Unimplemented method 'seed'"); } } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java index 567558a..5a4e5ea 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java @@ -9,27 +9,93 @@ public final class XMLValidatorV1 { private static final int MAX_COMMISSION = 90; public Result validate(GuessMarket seed) { + if (seed == null || seed.getGMEvents() == null || seed.getGMEvents().getGMEvent() == null) { + return Result.error("INVALID_XML", "The XML file contains no GM-events element."); + } - var seenIds = new HashSet(); - var duplicated = new HashSet(); + var events = seed.getGMEvents().getGMEvent(); + if (events.isEmpty()) { + return Result.error("EMPTY_EVENTS", "The XML file does not contain any events."); + } - for (var e : seed.getGMEvents().getGMEvent()) { + var seenIds = new HashSet(); - if (!seenIds.add(e.getId()) && duplicated.add(e.getId())) { + for (var e : events) { + // 1. Event ID must be positive integer (> 0) + if (e.getId() <= 0) { + return Result.error( + "INVALID_ID", + "Event ID must be a positive integer (> 0), found: " + e.getId()); + } + if (!seenIds.add(e.getId())) { return Result.error( "DUPLICATE_ID", - "Event id " + e.getId() + " appears more than once, every event id must be unique."); + "Event ID " + e.getId() + " appears more than once. Every event ID must be unique."); } + // 2. Event name must not be empty + if (e.getName() == null || e.getName().isEmpty() || String.join(" ", e.getName()).isBlank()) { + return Result.error("MISSING_NAME", "Event ID " + e.getId() + " has an empty name."); + } + + // 3. Event description must not be empty + if (e.getDescription() == null || e.getDescription().isBlank()) { + return Result.error("MISSING_DESCRIPTION", "Event ID " + e.getId() + " has an empty description."); + } + + // 4. Commission validation + if (e.getComision() == null) { + return Result.error("MISSING_COMMISSION", "Event ID " + e.getId() + " is missing commission."); + } var pct = e.getComision().getValue(); if (pct < MIN_COMMISSION || pct > MAX_COMMISSION) { return Result.error( "COMMISSION_OUT_OF_RANGE", - "Commission is %d%%, it must be between %d%% and %d%%." - .formatted(pct, MIN_COMMISSION, MAX_COMMISSION)); + "Event ID " + e.getId() + " commission is " + pct + "%. It must be between " + MIN_COMMISSION + "% and " + MAX_COMMISSION + "%."); + } + + var type = e.getComision().getType(); + if (type == null || (!type.equalsIgnoreCase("on-close") && !type.equalsIgnoreCase("on-purchase"))) { + return Result.error( + "INVALID_COMMISSION_TYPE", + "Event ID " + e.getId() + " has invalid commission type '" + type + "'. Must be 'on-close' or 'on-purchase'."); + } + + // 5. GM-options validation (exactly 2 options for Exercise 1) + if (e.getGMOptions() == null || e.getGMOptions().getGMOption() == null) { + return Result.error("MISSING_OPTIONS", "Event ID " + e.getId() + " is missing GM-options."); + } + var options = e.getGMOptions().getGMOption(); + if (options.size() != 2) { + return Result.error( + "INVALID_OPTIONS_COUNT", + "Event ID " + e.getId() + " must have exactly 2 options for Exercise 1, but found " + options.size() + "."); + } + var opt0 = options.get(0) == null ? "" : options.get(0).trim(); + var opt1 = options.get(1) == null ? "" : options.get(1).trim(); + if (opt0.isEmpty() || opt1.isEmpty()) { + return Result.error("EMPTY_OPTION", "Event ID " + e.getId() + " has an empty option name."); + } + if (opt0.equalsIgnoreCase(opt1)) { + return Result.error("DUPLICATE_OPTION", "Event ID " + e.getId() + " has duplicate option names: '" + opt0 + "'."); + } + + // 6. GM-method and LMSR validation + if (e.getGMMethod() == null) { + return Result.error("MISSING_METHOD", "Event ID " + e.getId() + " is missing GM-method."); + } + if (e.getGMMethod().getGMLMSR() == null) { + return Result.error("INVALID_METHOD", "Event ID " + e.getId() + " must use LMSR trading method in Exercise 1."); + } + var b = e.getGMMethod().getGMLMSR().getB(); + if (b <= 0) { + return Result.error( + "INVALID_LIQUIDITY", + "Event ID " + e.getId() + " has invalid liquidity b=" + b + ". b must be a positive integer (> 0)."); } } return Result.ok(); } } + -- cgit v1.2.3