From a52712b063606664274786cdb410176125816b66 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Mon, 30 Mar 2026 16:40:32 +0700 Subject: [PATCH] Complete batch 2 ownership import hardening --- FEATURE_SPEC.md | 20 +- TODO.md | 27 +- docs/SMOKE.md | 4 +- scripts/live-smoke.sh | 70 ++++- src/cli/ownership.rs | 39 ++- src/ownership/parser.rs | 139 ++++++++- src/ownership/remote.rs | 161 ++++++++-- tests/cli.rs | 284 +++++++++++++++++- tests/fixtures/ksei_above5_stext_excerpt.xml | 8 + ...sei_announcement_wrapper_stext_excerpt.xml | 8 + .../ksei_investor_type_stext_excerpt.xml | 10 + 11 files changed, 703 insertions(+), 67 deletions(-) create mode 100644 tests/fixtures/ksei_above5_stext_excerpt.xml create mode 100644 tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml create mode 100644 tests/fixtures/ksei_investor_type_stext_excerpt.xml diff --git a/FEATURE_SPEC.md b/FEATURE_SPEC.md index 5d3de1a..74f6b0f 100644 --- a/FEATURE_SPEC.md +++ b/FEATURE_SPEC.md @@ -26,15 +26,16 @@ Use `TODO.md` as the execution log and smoke-history record. ## Verified Current State -- Current automated coverage is `153` tests: `102` unit and `51` integration. +- Current automated coverage is `168` tests: `110` unit and `58` integration. - Reusable smoke coverage exists via `scripts/live-smoke.sh`; command groups are documented in `docs/SMOKE.md`. - The latest smoke notes in `TODO.md` report passing live table and JSON checks for all shipped `stocks` commands. - Cache/offline parity, JSON startup-error handling, screener input validation, and the recent MSN output cleanups have already been completed. - KSEI ownership import/query is now verified end-to-end from a local March 2026 PDF file into SQLite, with direct CLI reads through `ownership releases` and `ownership ticker`. - Remote ownership ingestion should now be treated as an IDX PDF discovery-and-fetch problem first, not a hardcoded KSEI PDF URL problem: direct IDX announcement PDFs exist, but the hashed asset URL must be discovered from an IDX listing/announcement surface and fetched with browser-like behavior. -- The CLI now has an explicit `idx ownership discover` surface that enumerates the latest hashed BEI ownership report URLs. +- The CLI now has an explicit `idx ownership discover` surface that enumerates the latest hashed BEI ownership report URLs and defaults to the supported `above1` family. - Live verification on `2026-03-29` confirmed that the parser-compatible discovered source is currently the `Pemegang Saham di atas 1% (KSEI)` `lamp1` attachment, and `idx ownership import --url` now works end to end for that discovered BEI PDF. -- The currently discoverable `above 5%` and `investor-type` BEI families remain different schemas and should now be treated as legacy / unsupported input outside the current holder-register parser contract. +- The currently discoverable `above 5%` and `investor-type` BEI families remain different schemas and are now classified and rejected explicitly during import instead of falling through to a generic zero-row parse failure. +- Ownership smoke coverage now includes a dedicated `ownership-import` group that verifies live supported import plus expected unsupported-family failures. This means the main gap is no longer endpoint coverage. The remaining work is architecture cleanup, a few correctness edge cases, and selective UX expansion on top of already-shipped commands. @@ -61,7 +62,7 @@ The remaining work is architecture cleanup, a few correctness edge cases, and se | News | `idx stocks news` | Implemented | Fixture-backed CLI coverage exists | | Screener | `idx stocks screen` | Implemented with gaps | Validation landed; expression/preset workflow is still future work | | MSN charts | `idx stocks history --history-provider msn` | Missing | Explicit MSN history still returns unsupported for IDX | -| KSEI ownership import/query | `idx ownership import --file`, `idx ownership import --url`, `idx ownership releases`, `idx ownership ticker` | Implemented with gaps | Local PDF import and SQLite-backed query flow are verified against the March 2026 KSEI release; remote IDX import now works for the discovered `above 1%` `lamp1` BEI attachment, while legacy `above 5%` and `investor-type` BEI report families still need explicit unsupported-input handling | +| KSEI ownership import/query | `idx ownership import --file`, `idx ownership import --url`, `idx ownership releases`, `idx ownership ticker` | Implemented | Local PDF import and SQLite-backed query flow are verified against the March 2026 KSEI release; remote IDX import now works for the discovered `above 1%` `lamp1` BEI attachment, and legacy `above 5%` / `investor-type` BEI report families are rejected explicitly | | Bing ownership CLI | `idx ownership import --fetch-bing` | Not implemented | Client groundwork exists, CLI import path is still deferred | --- @@ -82,6 +83,7 @@ The following items should no longer be treated as active backlog in this spec: - Baseline parser and CLI regression coverage for the shipped MSN-only command set - KSEI ownership parser hardening for the March 2026 live PDF layout, including the merged `DATE + SHARE_CODE` segment and `D`/`A` locality markers - Real KSEI ownership CLI verification from local file import into SQLite (`7261` rows across `955` tickers on `2026-03-28`) +- Ownership remote-import hardening for the `above1` contract, including direct-PDF-only `--url` input, discovery status output, explicit legacy-schema rejection, and live ownership-import smoke coverage If any of the above regress, capture that in `TODO.md` as a new finding rather than reopening the old section here wholesale. @@ -158,7 +160,7 @@ Current state: Why it matters: - The ownership feature now parses and stores live ownership data correctly from both local files and the currently discoverable `above 1%` BEI `lamp1` attachment. -- The remaining gap is not basic remote import anymore; it is hardening the supported `above 1%` path and clearly rejecting other discovered BEI schemas that do not match the holder-register contract. +- Batch 2 hardening is now complete; the remaining ownership roadmap is snapshot publishing / `ownership sync` plus optional fallback ingest and cross-check work. Done when: - The CLI can discover the latest parser-compatible IDX ownership PDF URL from an IDX listing/announcement surface without hardcoded monthly paths. @@ -167,12 +169,8 @@ Done when: - Unsupported BEI report families are classified and rejected explicitly instead of failing later with a generic zero-row parse error. Roadmap: -1. Keep discovery and import flows centered on the parser-compatible `above 1%` family and its `lamp1` attachment. -2. Reuse the existing `curl-impersonate` pattern for browser-like PDF fetches. -3. Add schema classification / unsupported-input UX for the legacy `above 5%` and `investor-type` BEI PDFs. -4. Decide whether the default discover output should be `above1`-first, with legacy families retained only for diagnostic use. -5. Publish maintained SQLite snapshot artifacts and add `idx ownership sync` after remote IDX import is stable. -6. Optionally add KSEI ZIP/TXT ingest later as fallback or cross-check input. +1. Publish maintained SQLite snapshot artifacts and add `idx ownership sync` after remote IDX import is stable. +2. Optionally add KSEI ZIP/TXT ingest later as fallback or cross-check input. ### P1 - UX and output contract cleanup diff --git a/TODO.md b/TODO.md index 2957507..d2e2e00 100644 --- a/TODO.md +++ b/TODO.md @@ -78,17 +78,17 @@ - the `above 5%` and `investor-type` BEI families remain different schemas; as of `2026-03-30`, they should be treated as legacy / unsupported input rather than new parser targets ### Batch 2 — Above-1 hardening and unsupported-input UX -- [ ] Standardize the supported remote-import contract on the `Pemegang Saham di atas 1% (KSEI)` holder-register layout and its `lamp1` attachment shape -- [ ] Add BEI PDF schema classification before parse/import so `ownership import --url` can reject non-holder-register PDFs before the parser runs -- [ ] Improve CLI error messages and fallback behavior for discovery failure, fetch failure, invalid remote content, and known-but-unsupported legacy BEI schema variants -- [ ] Capture live-like fixtures for the current discoverable `investor-type` and `above 5%` BEI attachments (or their `mutool` `stext` extracts) so unsupported-input detection and failure UX are regression-tested -- [ ] Decide and document whether `ownership discover` should default to `above1` output while keeping legacy families available only for diagnostic use -- [ ] Decide and document whether `ownership import --url` accepts only direct PDF URLs or can also accept an IDX listing page as input -- [ ] Add regression coverage for Cloudflare/HTML responses, missing announcement links, duplicate release imports, and unsupported BEI schema detections -- [ ] Batch 2 verification: `cargo build` -- [ ] Batch 2 verification: `cargo clippy -- -D warnings` -- [ ] Batch 2 verification: `cargo test` -- [ ] Batch 2 verification: ownership-focused smoke checks cover successful remote import plus expected failure UX +- [x] Standardize the supported remote-import contract on the `Pemegang Saham di atas 1% (KSEI)` holder-register layout and its `lamp1` attachment shape +- [x] Add BEI PDF schema classification before parse/import so `ownership import --url` can reject non-holder-register PDFs before the parser runs +- [x] Improve CLI error messages and fallback behavior for discovery failure, fetch failure, invalid remote content, and known-but-unsupported legacy BEI schema variants +- [x] Capture live-like fixtures for the current discoverable `investor-type` and `above 5%` BEI attachments (or their `mutool` `stext` extracts) so unsupported-input detection and failure UX are regression-tested +- [x] Decide and document whether `ownership discover` should default to `above1` output while keeping legacy families available only for diagnostic use +- [x] Decide and document whether `ownership import --url` accepts only direct PDF URLs or can also accept an IDX listing page as input +- [x] Add regression coverage for Cloudflare/HTML responses, missing announcement links, duplicate release imports, and unsupported BEI schema detections +- [x] Batch 2 verification: `cargo build` +- [x] Batch 2 verification: `cargo clippy -- -D warnings` +- [x] Batch 2 verification: `cargo test` +- [x] Batch 2 verification: ownership-focused smoke checks cover successful remote import plus expected failure UX ### Batch 3 — Snapshot publishing + sync - [ ] Design maintained SQLite snapshot publishing after remote IDX import is stable @@ -157,6 +157,11 @@ - [x] Live `ownership import --url` checks against the currently discoverable `above 5%` and `investor-type` BEI PDFs still fail with `no KSEI rows parsed from PDF`, which is expected until unsupported-family detection / rejection UX lands - [x] Live `mutool` inspection of the current discoverable `investor-type` BEI attachment shows a stock-level aggregate matrix (`DATE`, `STOCK_CODE`, `NUMBER_OF_SHARES`, investor-type columns, holder-size buckets), not the holder-level KSEI register schema the current parser imports - [x] Live `mutool` inspection of the current discoverable `above 5%` BEI attachment shows a member/tampungan report (`INVS`, member names, `KSEI UNTUK CLOSED MEMBER-...` labels), not the raw KSEI holder-register layout +- [x] `ownership discover` now defaults to the supported `above1` family, surfaces per-URL importability status, and orders the importable `lamp1` attachment first so `--limit 1` yields the current supported PDF URL +- [x] `ownership import --url` now accepts only direct PDF URLs; IDX listing/announcement page URLs fail fast with guidance to run `ownership discover` +- [x] Valid-but-unsupported BEI PDFs now fail before row parsing with explicit schema-aware errors (`announcement_wrapper`, legacy `above5`, legacy `investor-type`) instead of the old generic `no KSEI rows parsed from PDF` path +- [x] Regression coverage now covers default `ownership discover` behavior, status visibility, listing-page rejection, duplicate SHA imports, and explicit unsupported-schema detection with compact `stext` fixtures plus fake-`mutool` CLI tests +- [x] New `ownership-import` smoke coverage now discovers the current live `above1`/`above5`/`investor-type` URLs, imports the supported `above1` attachment successfully, and confirms the legacy families fail with explicit unsupported-schema UX (`tmp/live-smoke/20260330-160201`) ## 🐛 Known Issues - [ ] Yahoo Finance returns 429 from datacenter IPs occasionally diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 6edb24a..740fcbd 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -23,7 +23,7 @@ scripts/live-smoke.sh --dry-run --mode full - `live` runs the default real-network baseline: `general`, `live-table`, and `ownership` - `mock` runs the deterministic baseline: `general`, `mock`, `cache`, `routing`, `errors`, and `ownership` -- `full` runs every group, including live JSON output checks +- `full` runs every group, including live JSON output checks and ownership-import verification ## Groups @@ -35,10 +35,12 @@ scripts/live-smoke.sh --dry-run --mode full - `routing`: Yahoo/MSN provider routing plus explicit MSN history unsupported behavior - `errors`: JSON error contract and invalid flag/input checks - `ownership`: safe ownership smoke checks that do not require imported ownership data +- `ownership-import`: live discovery/import hardening checks for supported `above1` import plus expected unsupported legacy-family failures ## Notes - The runner forces `IDX_OUTPUT=table` as its default environment so table cases stay stable; JSON checks use `-o json` explicitly. - Cache-group warm cases clear the smoke cache before they run so each warm/offline/stale sequence starts clean and stale-cache assertions are not masked by earlier groups. - Ownership commands that need imported data are intentionally not part of the baseline runner yet. The current baseline only covers `ownership releases` and the known unsupported `ownership import --fetch-bing`. +- The new `ownership-import` group is intentionally opt-in for explicit `--group ownership-import` runs or `--mode full`; it discovers live URLs first, imports the supported `above1` attachment into the temp DB, then asserts the current `above5` and `investor-type` URLs fail with explicit unsupported-schema UX. - When a case fails, inspect the per-case log in `tmp/live-smoke/.../logs/` before updating `TODO.md` or `FEATURE_SPEC.md`. diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 44bee9e..5f0d8a5 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -28,6 +28,9 @@ CACHE_HOME="" DATA_HOME="" OWNERSHIP_DB="" LOG_DIR="" +OWNERSHIP_ABOVE1_URL="" +OWNERSHIP_ABOVE5_URL="" +OWNERSHIP_INVESTOR_TYPE_URL="" declare -a BASE_ENV=() @@ -59,6 +62,7 @@ Groups: routing provider routing and explicit unsupported checks errors JSON error contract and invalid-flag checks ownership ownership commands that are safe without imported data + ownership-import live ownership discovery/import hardening checks Examples: scripts/live-smoke.sh @@ -124,7 +128,12 @@ group_requested() { should_run_group() { local group="$1" - mode_allows_group "$group" && group_requested "$group" + if [[ ${#GROUP_FILTERS[@]} -gt 0 ]]; then + group_requested "$group" + return + fi + + mode_allows_group "$group" } add_case() { @@ -242,6 +251,12 @@ register_cases() { add_case "ownership" "releases-empty" "0" "" "ownership releases" "No ownership releases imported yet." add_case "ownership" "fetch-bing-unsupported" "1" "" "ownership import --fetch-bing $live_symbol" "--fetch-bing import is not implemented yet" + + add_case "ownership-import" "discover-default-above1" "0" "" "ownership discover --limit 1" "supported" + add_case "ownership-import" "import-supported-above1" "0" "" "ownership import --url $OWNERSHIP_ABOVE1_URL" "Imported " + add_case "ownership-import" "releases-after-import" "0" "" "ownership releases" "$OWNERSHIP_ABOVE1_URL" + add_case "ownership-import" "import-legacy-above5" "1" "" "ownership import --url $OWNERSHIP_ABOVE5_URL" "legacy IDX \`above5\` ownership PDFs are not supported for import" + add_case "ownership-import" "import-legacy-investor-type" "1" "" "ownership import --url $OWNERSHIP_INVESTOR_TYPE_URL" "legacy IDX \`investor-type\` ownership PDFs are not supported for import" } parse_args() { @@ -400,6 +415,50 @@ prepare_environment() { bootstrap_case "ownership-db" config set ownership.db_path "$OWNERSHIP_DB" } +discover_ownership_import_url() { + local family="$1" + local log_file="$LOG_DIR/bootstrap-discover-${family}.log" + local url + + if (( DRY_RUN )); then + case "$family" in + above1) printf 'https://example.invalid/above1-lamp1.pdf' ;; + above5) printf 'https://example.invalid/above5-lamp1.pdf' ;; + investor-type) printf 'https://example.invalid/investor-type-lamp1.pdf' ;; + *) return 1 ;; + esac + return 0 + fi + + ( + cd "$ROOT_DIR" || exit 1 + env "${BASE_ENV[@]}" "$BIN_PATH" -o json ownership discover --family "$family" --limit 1 + ) >"$log_file" 2>&1 + + if [[ $? -ne 0 ]]; then + echo "bootstrap failed for ownership-discover-$family; see $log_file" >&2 + exit 1 + fi + + url="$(grep -m1 '"pdf_url"' "$log_file" | sed -E 's/.*"pdf_url": "([^"]+)".*/\1/')" + if [[ -z "$url" ]]; then + echo "bootstrap failed for ownership-discover-$family: could not parse pdf_url from $log_file" >&2 + exit 1 + fi + + printf '%s' "$url" +} + +prepare_ownership_import_inputs() { + if ! should_run_group "ownership-import"; then + return 0 + fi + + OWNERSHIP_ABOVE1_URL="$(discover_ownership_import_url above1)" + OWNERSHIP_ABOVE5_URL="$(discover_ownership_import_url above5)" + OWNERSHIP_INVESTOR_TYPE_URL="$(discover_ownership_import_url investor-type)" +} + cache_case_starts_fresh() { local group="$1" local label="$2" @@ -494,7 +553,6 @@ main() { parse_args "$@" prepare_paths - register_cases printf 'mode: %s\n' "$MODE" printf 'workdir: %s\n' "$WORKDIR" @@ -506,6 +564,14 @@ main() { build_binary prepare_environment + prepare_ownership_import_inputs + register_cases + + if should_run_group "ownership-import"; then + printf 'ownership above1 url: %s\n' "$OWNERSHIP_ABOVE1_URL" + printf 'ownership above5 url: %s\n' "$OWNERSHIP_ABOVE5_URL" + printf 'ownership investor-type url: %s\n' "$OWNERSHIP_INVESTOR_TYPE_URL" + fi for case_line in "${CASES[@]}"; do run_case_line "$case_line" diff --git a/src/cli/ownership.rs b/src/cli/ownership.rs index e4e1001..bdbeffa 100644 --- a/src/cli/ownership.rs +++ b/src/cli/ownership.rs @@ -56,8 +56,8 @@ pub enum OwnershipCommand { #[derive(Debug, Args)] pub struct DiscoverArgs { - /// Report family to discover: all, above1, above5, or investor-type. - #[arg(long, default_value = "all")] + /// Report family to discover: above1 (default), all, above5, or investor-type. + #[arg(long, default_value = "above1")] pub family: String, /// Maximum number of discovered report URLs to print. #[arg(long, default_value_t = 6)] @@ -204,12 +204,15 @@ fn handle_discover(args: &DiscoverArgs, config: &IdxConfig) -> Result<(), IdxErr table .load_preset(UTF8_FULL) .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec!["DATE", "FAMILY", "KIND", "FILE", "TITLE", "URL"]); + .set_header(vec![ + "DATE", "FAMILY", "STATUS", "KIND", "FILE", "TITLE", "URL", + ]); for report in reports { table.add_row(vec![ Cell::new(report.publish_date.split('T').next().unwrap_or("-")), Cell::new(report.family.label()), + Cell::new(report.status.label()), Cell::new(if report.is_attachment { "attachment" } else { @@ -859,11 +862,13 @@ fn resolve_pdf_input(args: &ImportArgs) -> Result, IdxE } if let Some(url) = &args.url { - let target = cache_pdf_path(url)?; - download_pdf(url, &target)?; + let trimmed = url.trim(); + validate_import_url(trimmed)?; + let target = cache_pdf_path(trimmed)?; + download_pdf(trimmed, &target)?; return Ok(Some(ResolvedPdfInput { pdf_path: target, - source_url: Some(url.clone()), + source_url: Some(trimmed.to_string()), })); } @@ -895,6 +900,28 @@ fn cache_pdf_path(url: &str) -> Result { Ok(raw_dir.join(file_name)) } +fn validate_import_url(url: &str) -> Result<(), IdxError> { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err(IdxError::InvalidInput( + "ownership import --url accepts direct PDF URLs only".to_string(), + )); + } + + let normalized = trimmed.to_ascii_lowercase(); + let normalized = normalized + .split(['?', '#']) + .next() + .unwrap_or(normalized.as_str()); + if normalized.ends_with(".pdf") { + return Ok(()); + } + + Err(IdxError::InvalidInput( + "ownership import --url accepts direct PDF URLs only; run `idx ownership discover` first to find the current supported attachment".to_string(), + )) +} + fn download_pdf(url: &str, target: &Path) -> Result<(), IdxError> { if is_idx_url(url) { return remote::download_idx_pdf(url, target); diff --git a/src/ownership/parser.rs b/src/ownership/parser.rs index 84bcf23..aa819ed 100644 --- a/src/ownership/parser.rs +++ b/src/ownership/parser.rs @@ -26,6 +26,49 @@ const HEADER_LABELS: &[&str] = &[ "PERCENTAGE", ]; +const HOLDER_REGISTER_SCHEMA_MARKERS: &[&str] = &[ + "TEXT=\"DATE\"", + "TEXT=\"SHARE_CODE\"", + "TEXT=\"INVESTOR_NAME\"", + "TEXT=\"INVESTOR_TYPE\"", + "TEXT=\"LOCAL_FOREIGN\"", + "TEXT=\"TOTAL_HOLDING_SHARES\"", + "TEXT=\"PERCENTAGE\"", +]; +const ANNOUNCEMENT_WRAPPER_SCHEMA_MARKERS: &[&str] = + &["TEXT=\"PENGUMUMAN\"", "PT BURSA EFEK INDONESIA (BEI)"]; +const ABOVE_FIVE_SCHEMA_MARKERS: &[&str] = &[ + "TEXT=\"INVS\"", + "REKENING TAMPUNGAN KSEI", + "CLOSED MEMBER-", +]; +const INVESTOR_TYPE_SCHEMA_MARKERS: &[&str] = &[ + "TEXT=\"STOCK_CODE\"", + "TEXT=\"NUMBER_OF_SHARES\"", + "TEXT=\"FOREIGN\"", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OwnershipPdfSchema { + HolderRegister, + AnnouncementWrapper, + LegacyAboveFivePercent, + LegacyInvestorType, + Unknown, +} + +impl OwnershipPdfSchema { + pub fn label(self) -> &'static str { + match self { + Self::HolderRegister => "holder_register", + Self::AnnouncementWrapper => "announcement_wrapper", + Self::LegacyAboveFivePercent => "legacy_above5", + Self::LegacyInvestorType => "legacy_investor_type", + Self::Unknown => "unknown", + } + } +} + #[derive(Debug, Clone)] struct PageLine { x: f32, @@ -33,9 +76,8 @@ struct PageLine { text: String, } -/// Parse a KSEI ownership PDF into raw rows. -/// Shells out to `mutool` for XML extraction, then parses with quick-xml. -pub fn parse_ksei_pdf(path: &Path) -> Result, IdxError> { +/// Extract mutool stext XML from a PDF file. +pub fn extract_pdf_stext(path: &Path) -> Result { check_mutool()?; let output = Command::new("mutool") @@ -59,6 +101,35 @@ pub fn parse_ksei_pdf(path: &Path) -> Result, IdxError> { let xml = String::from_utf8(output.stdout) .map_err(|e| IdxError::PdfParseError(format!("invalid utf-8 stext output: {e}")))?; + Ok(xml) +} + +/// Classify a PDF schema from mutool stext XML before the row parser runs. +pub fn classify_stext_xml(xml: &str) -> OwnershipPdfSchema { + let normalized = xml.to_ascii_uppercase(); + + if count_schema_markers(&normalized, HOLDER_REGISTER_SCHEMA_MARKERS) >= 5 { + return OwnershipPdfSchema::HolderRegister; + } + if count_schema_markers(&normalized, ABOVE_FIVE_SCHEMA_MARKERS) >= 2 { + return OwnershipPdfSchema::LegacyAboveFivePercent; + } + if count_schema_markers(&normalized, INVESTOR_TYPE_SCHEMA_MARKERS) >= 3 { + return OwnershipPdfSchema::LegacyInvestorType; + } + if count_schema_markers(&normalized, ANNOUNCEMENT_WRAPPER_SCHEMA_MARKERS) >= 2 { + return OwnershipPdfSchema::AnnouncementWrapper; + } + + OwnershipPdfSchema::Unknown +} + +/// Parse a KSEI ownership PDF into raw rows. +/// Shells out to `mutool` for XML extraction, classifies the schema, +/// and only parses the supported holder-register layout. +pub fn parse_ksei_pdf(path: &Path) -> Result, IdxError> { + let xml = extract_pdf_stext(path)?; + ensure_supported_schema(classify_stext_xml(&xml))?; parse_stext_xml(&xml) } @@ -116,6 +187,31 @@ pub fn check_mutool() -> Result<(), IdxError> { Ok(()) } +fn ensure_supported_schema(schema: OwnershipPdfSchema) -> Result<(), IdxError> { + match schema { + OwnershipPdfSchema::HolderRegister => Ok(()), + OwnershipPdfSchema::AnnouncementWrapper => Err(IdxError::Unsupported( + "IDX announcement wrapper PDFs are not importable; run `idx ownership discover` and use the `lamp1` attachment URL".to_string(), + )), + OwnershipPdfSchema::LegacyAboveFivePercent => Err(IdxError::Unsupported( + "legacy IDX `above5` ownership PDFs are not supported for import; only the `above1` holder-register `lamp1` attachment is supported".to_string(), + )), + OwnershipPdfSchema::LegacyInvestorType => Err(IdxError::Unsupported( + "legacy IDX `investor-type` ownership PDFs are not supported for import; only the `above1` holder-register `lamp1` attachment is supported".to_string(), + )), + OwnershipPdfSchema::Unknown => Err(IdxError::ParseError( + "PDF did not match the supported KSEI holder-register layout".to_string(), + )), + } +} + +fn count_schema_markers(haystack: &str, markers: &[&str]) -> usize { + markers + .iter() + .filter(|marker| haystack.contains(**marker)) + .count() +} + fn parse_line_attrs( reader: &Reader<&[u8]>, event: &BytesStart<'_>, @@ -461,7 +557,9 @@ fn is_percentage_like(s: &str) -> bool { mod tests { use std::path::Path; - use super::{check_mutool, parse_ksei_pdf, parse_stext_xml}; + use super::{ + OwnershipPdfSchema, check_mutool, classify_stext_xml, parse_ksei_pdf, parse_stext_xml, + }; #[test] fn test_parse_stext_xml_live_like_lines_extract_rows() { @@ -513,6 +611,39 @@ mod tests { assert_eq!(row.percentage, "41,10"); } + #[test] + fn classify_stext_xml_detects_supported_holder_register_schema() { + let xml = include_str!("../../tests/fixtures/ksei_above1_stext_excerpt.xml"); + assert_eq!(classify_stext_xml(xml), OwnershipPdfSchema::HolderRegister); + } + + #[test] + fn classify_stext_xml_detects_announcement_wrapper_schema() { + let xml = include_str!("../../tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml"); + assert_eq!( + classify_stext_xml(xml), + OwnershipPdfSchema::AnnouncementWrapper + ); + } + + #[test] + fn classify_stext_xml_detects_legacy_above5_schema() { + let xml = include_str!("../../tests/fixtures/ksei_above5_stext_excerpt.xml"); + assert_eq!( + classify_stext_xml(xml), + OwnershipPdfSchema::LegacyAboveFivePercent + ); + } + + #[test] + fn classify_stext_xml_detects_legacy_investor_type_schema() { + let xml = include_str!("../../tests/fixtures/ksei_investor_type_stext_excerpt.xml"); + assert_eq!( + classify_stext_xml(xml), + OwnershipPdfSchema::LegacyInvestorType + ); + } + #[test] fn test_parse_ksei_pdf_real_file_row_count() { if check_mutool().is_err() { diff --git a/src/ownership/remote.rs b/src/ownership/remote.rs index 6e99a71..c8b18f6 100644 --- a/src/ownership/remote.rs +++ b/src/ownership/remote.rs @@ -78,9 +78,36 @@ impl OwnershipReportFamily { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OwnershipReportStatus { + Supported, + AnnouncementOnly, + Unsupported, +} + +impl OwnershipReportStatus { + pub fn label(self) -> &'static str { + match self { + Self::Supported => "supported", + Self::AnnouncementOnly => "announcement_only", + Self::Unsupported => "unsupported", + } + } + + fn sort_rank(self) -> u8 { + match self { + Self::Supported => 0, + Self::AnnouncementOnly => 1, + Self::Unsupported => 2, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct DiscoveredOwnershipPdf { pub family: OwnershipReportFamily, + pub status: OwnershipReportStatus, pub listing_page_url: String, pub query_url: String, pub pdf_url: String, @@ -148,13 +175,13 @@ pub fn parse_announcement_page(raw: &str) -> Result let trimmed = raw.trim(); if trimmed.is_empty() { return Err(IdxError::Http( - "IDX ownership discovery returned an empty announcement payload".to_string(), + "IDX ownership discovery returned an empty announcement payload; the IDX announcement API did not return any JSON items".to_string(), )); } let normalized = trimmed.to_ascii_lowercase(); if normalized.starts_with(">(); attachments.sort_by(|left, right| { - left.is_attachment - .cmp(&right.is_attachment) + left.status + .sort_rank() + .cmp(&right.status.sort_rank()) + .then_with(|| right.is_attachment.cmp(&left.is_attachment)) .then_with(|| left.original_filename.cmp(&right.original_filename)) }); if attachments.is_empty() { return Err(IdxError::Http(format!( - "failed to discover IDX ownership reports from {query_url}: matching announcement had no PDF attachments" + "failed to discover {} IDX ownership reports from {query_url}: matching announcement had no PDF attachments", + family.cli_name() ))); } @@ -257,7 +291,8 @@ pub fn discover_idx_ownership_reports( right .publish_date .cmp(&left.publish_date) - .then_with(|| left.is_attachment.cmp(&right.is_attachment)) + .then_with(|| left.status.sort_rank().cmp(&right.status.sort_rank())) + .then_with(|| right.is_attachment.cmp(&left.is_attachment)) .then_with(|| left.original_filename.cmp(&right.original_filename)) }); @@ -313,12 +348,12 @@ pub fn validate_pdf_payload(bytes: &[u8]) -> Result<(), IdxError> { let preview = String::from_utf8_lossy(&trimmed[..trimmed.len().min(256)]).to_ascii_lowercase(); if preview.contains(" String { .to_ascii_lowercase() } +fn classify_report_status( + family: OwnershipReportFamily, + is_attachment: bool, +) -> OwnershipReportStatus { + match (family, is_attachment) { + (OwnershipReportFamily::AboveOnePercent, true) => OwnershipReportStatus::Supported, + (OwnershipReportFamily::AboveOnePercent, false) => OwnershipReportStatus::AnnouncementOnly, + _ => OwnershipReportStatus::Unsupported, + } +} + fn parse_pdf_path(raw: &str) -> Option> { serde_json::from_str::>(raw).ok() } @@ -455,8 +501,8 @@ fn percent_encode(value: &str) -> String { mod tests { use super::{ AnnouncementPage, IDX_ANNOUNCEMENT_LISTING_URL, OwnershipReportFamily, - build_announcement_query_url, parse_announcement_page, select_latest_ownership_reports, - validate_pdf_payload, + OwnershipReportStatus, build_announcement_query_url, parse_announcement_page, + select_latest_ownership_reports, validate_pdf_payload, }; use crate::error::IdxError; @@ -489,22 +535,23 @@ mod tests { assert_eq!(discovered[0].publish_date, "2026-03-27T16:34:20"); assert_eq!( discovered[0].pdf_url, - "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/5d31bb6f49_announcement.pdf" - ); - assert_eq!( - discovered[0].original_filename.as_deref(), - Some("20260327_Semua Emiten Saham_Pengumuman Bursa_32055594.pdf") - ); - assert!(!discovered[0].is_attachment); - assert_eq!( - discovered[1].pdf_url, "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/4f5c4efc6f_bf70f249ac_lamp1.pdf" ); assert_eq!( - discovered[1].original_filename.as_deref(), + discovered[0].original_filename.as_deref(), Some("20260327_Semua Emiten Saham_Pengumuman Bursa_32055594_lamp1.pdf") ); - assert!(discovered[1].is_attachment); + assert_eq!(discovered[0].status, OwnershipReportStatus::Unsupported); + assert!(discovered[0].is_attachment); + assert_eq!( + discovered[1].pdf_url, + "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/5d31bb6f49_announcement.pdf" + ); + assert_eq!( + discovered[1].original_filename.as_deref(), + Some("20260327_Semua Emiten Saham_Pengumuman Bursa_32055594.pdf") + ); + assert!(!discovered[1].is_attachment); } #[test] @@ -547,17 +594,18 @@ mod tests { assert_eq!(discovered.len(), 2); assert_eq!(discovered[0].family, OwnershipReportFamily::AboveOnePercent); + assert_eq!(discovered[0].status, OwnershipReportStatus::Supported); assert_eq!(discovered[0].code.as_deref(), Some("Semua Emiten Saham")); assert_eq!( discovered[0].original_filename.as_deref(), - Some("20260310_Semua Emiten Saham_Pengumuman Bursa_32052554.pdf") + Some("20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf") ); - assert!(!discovered[0].is_attachment); + assert!(discovered[0].is_attachment); assert_eq!( - discovered[1].pdf_url, - "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/b9b638e5a8_8928aca255.pdf" + discovered[1].status, + OwnershipReportStatus::AnnouncementOnly ); - assert!(discovered[1].is_attachment); + assert!(!discovered[1].is_attachment); } #[test] @@ -601,11 +649,12 @@ mod tests { assert_eq!(discovered.len(), 2); assert_eq!( discovered[0].original_filename.as_deref(), - Some("20260302_Pengumuman Bursa_32040089.pdf") + Some("20260302_Pengumuman Bursa_32040089_lamp1.pdf") ); + assert_eq!(discovered[0].status, OwnershipReportStatus::Unsupported); assert_eq!( discovered[1].original_filename.as_deref(), - Some("20260302_Pengumuman Bursa_32040089_lamp1.pdf") + Some("20260302_Pengumuman Bursa_32040089.pdf") ); } @@ -650,6 +699,57 @@ mod tests { assert!(matches!(err, IdxError::Http(_))); } + #[test] + fn rejects_empty_announcement_payload() { + let err = parse_announcement_page(" ").expect_err("empty payload must fail"); + assert!(matches!(err, IdxError::Http(_))); + } + + #[test] + fn select_latest_reports_errors_when_family_is_missing() { + let raw = r#"{"Items":[],"ItemCount":0,"PageCount":0}"#; + let page = parse_announcement_page(raw).expect("empty page parses"); + + let err = select_latest_ownership_reports( + &page, + "https://www.idx.co.id/primary/NewsAnnouncement/GetAllAnnouncement?keywords=pemegang%20saham%20di%20atas%201&pageNumber=1&pageSize=10&lang=id", + OwnershipReportFamily::AboveOnePercent, + ) + .expect_err("missing announcement should fail"); + + assert!(matches!(err, IdxError::Http(_))); + assert!(err.to_string().contains("no matching announcement found")); + } + + #[test] + fn select_latest_reports_errors_when_matching_item_has_no_pdf() { + let raw = r#"{ + "Items": [ + { + "PublishDate": "2026-03-10T12:09:09", + "Title": "Pemegang Saham di atas 1% (KSEI)", + "AnnouncementType": "", + "Code": "Semua Emiten Saham", + "Attachments": [], + "PdfPath": "" + } + ], + "ItemCount": 1, + "PageCount": 1 + }"#; + let page = parse_announcement_page(raw).expect("page parses"); + + let err = select_latest_ownership_reports( + &page, + "https://www.idx.co.id/primary/NewsAnnouncement/GetAllAnnouncement?keywords=pemegang%20saham%20di%20atas%201&pageNumber=1&pageSize=10&lang=id", + OwnershipReportFamily::AboveOnePercent, + ) + .expect_err("missing pdf attachments should fail"); + + assert!(matches!(err, IdxError::Http(_))); + assert!(err.to_string().contains("no PDF attachments")); + } + #[test] fn accepts_pdf_header() { validate_pdf_payload(b"%PDF-1.7\n1 0 obj\n").expect("pdf header should pass"); @@ -660,5 +760,6 @@ mod tests { let err = validate_pdf_payload(b"blocked") .expect_err("html body must fail"); assert!(matches!(err, IdxError::Http(_))); + assert!(err.to_string().contains("PDF/direct attachment")); } } diff --git a/tests/cli.rs b/tests/cli.rs index 0b7c91a..f6ac8f6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,8 @@ use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::thread; @@ -65,6 +67,51 @@ fn spawn_single_response_server(content_type: &str, body: impl Into>) -> format!("http://{addr}") } +fn fake_pdf_bytes() -> Vec { + b"%PDF-1.7\n% idx-cli test fixture\n".to_vec() +} + +fn install_fake_mutool(root: &Path, xml: &str) -> PathBuf { + let bin_dir = root.join("fake-bin"); + fs::create_dir_all(&bin_dir).expect("create fake bin dir"); + let mutool_path = bin_dir.join("mutool"); + let script = format!( + "#!/bin/sh\n\ +if [ \"$1\" = \"--help\" ]; then\n\ + exit 0\n\ +fi\n\ +if [ \"$1\" = \"convert\" ]; then\n\ + cat <<'__IDX_XML__'\n\ +{xml}\n\ +__IDX_XML__\n\ + exit 0\n\ +fi\n\ +echo \"unexpected mutool args: $@\" >&2\n\ +exit 1\n" + ); + fs::write(&mutool_path, script).expect("write fake mutool"); + #[cfg(unix)] + { + let mut perms = fs::metadata(&mutool_path) + .expect("fake mutool metadata") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&mutool_path, perms).expect("set fake mutool perms"); + } + bin_dir +} + +fn prepend_path(dir: &Path) -> String { + match std::env::var("PATH") { + Ok(current) if !current.is_empty() => format!("{}:{current}", dir.display()), + _ => dir.display().to_string(), + } +} + +fn pdf_url(base: &str, name: &str) -> String { + format!("{base}/{name}.pdf") +} + #[test] fn help_works() { test_bin("help").arg("--help").assert().success(); @@ -695,18 +742,251 @@ fn ownership_discover_supports_above1_family() { )); } +#[test] +fn ownership_discover_defaults_to_above1_and_prefers_supported_attachment() { + let body = r#"{ + "Items": [ + { + "PublishDate": "2026-03-10T12:09:09", + "Title": "Pemegang Saham di atas 1% (KSEI)", + "AnnouncementType": "", + "Code": "Semua Emiten Saham", + "Attachments": [ + { + "PDFFilename": "d67ebf37e6_10d4080288.pdf", + "FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/d67ebf37e6_10d4080288.pdf", + "IsAttachment": 0, + "OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554.pdf" + }, + { + "PDFFilename": "b9b638e5a8_8928aca255.pdf", + "FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/b9b638e5a8_8928aca255.pdf", + "IsAttachment": 1, + "OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf" + } + ], + "PdfPath": "" + } + ], + "ItemCount": 1, + "PageSize": 10, + "PageNumber": 1, + "PageCount": 1 + }"#; + let json_url = spawn_single_response_server("application/json", body.to_string()); + + let output = test_bin("ownership-discover-default") + .env("IDX_CURL_IMPERSONATE_BIN", "curl") + .env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url) + .env( + "IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL", + "http://127.0.0.1/pengumuman", + ) + .args(["ownership", "discover", "--limit", "1"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let stdout = String::from_utf8(output).expect("utf8 stdout"); + assert!(stdout.contains("Above 1%")); + assert!(stdout.contains("supported")); + assert!(stdout.contains("20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf")); + assert!(!stdout.contains("20260310_Semua Emiten Saham_Pengumuman Bursa_32052554.pdf\n")); +} + +#[test] +fn ownership_discover_json_includes_status() { + let body = r#"{ + "Items": [ + { + "PublishDate": "2026-03-10T12:09:09", + "Title": "Pemegang Saham di atas 1% (KSEI)", + "AnnouncementType": "", + "Code": "Semua Emiten Saham", + "Attachments": [ + { + "PDFFilename": "d67ebf37e6_10d4080288.pdf", + "FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/d67ebf37e6_10d4080288.pdf", + "IsAttachment": 0, + "OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554.pdf" + }, + { + "PDFFilename": "b9b638e5a8_8928aca255.pdf", + "FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/b9b638e5a8_8928aca255.pdf", + "IsAttachment": 1, + "OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf" + } + ], + "PdfPath": "" + } + ], + "ItemCount": 1, + "PageSize": 10, + "PageNumber": 1, + "PageCount": 1 + }"#; + let json_url = spawn_single_response_server("application/json", body.to_string()); + + test_bin("ownership-discover-json-status") + .env("IDX_CURL_IMPERSONATE_BIN", "curl") + .env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url) + .env( + "IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL", + "http://127.0.0.1/pengumuman", + ) + .args(["-o", "json", "ownership", "discover", "--limit", "1"]) + .assert() + .success() + .stdout(predicate::str::contains("\"status\": \"supported\"")); +} + #[test] fn ownership_import_url_rejects_html_response_before_pdf_parse() { - let html_url = spawn_single_response_server( + let html_base = spawn_single_response_server( "text/html; charset=utf-8", "blocked", ); + let html_url = pdf_url(&html_base, "blocked"); test_bin("ownership-import-url-html") .args(["ownership", "import", "--url", &html_url]) .assert() .failure() - .stderr(predicate::str::contains("returned HTML instead of a PDF")); + .stderr(predicate::str::contains( + "returned HTML instead of a PDF/direct attachment", + )); +} + +#[test] +fn ownership_import_url_rejects_listing_page_inputs() { + test_bin("ownership-import-url-listing-page") + .args([ + "ownership", + "import", + "--url", + "https://www.idx.co.id/id/berita/pengumuman/", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "ownership import --url accepts direct PDF URLs only", + )) + .stderr(predicate::str::contains("ownership discover")); +} + +#[test] +fn ownership_import_url_supported_pdf_succeeds_with_fake_mutool() { + let root = test_env_dir("ownership-import-supported-remote"); + let db_path = root.join("ownership.db"); + let fake_mutool_dir = install_fake_mutool( + &root, + include_str!("fixtures/ksei_above1_stext_excerpt.xml"), + ); + let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); + let pdf_url = pdf_url(&pdf_base, "supported"); + + bin_with_root(&root) + .args([ + "config", + "set", + "ownership.db_path", + db_path.to_str().unwrap(), + ]) + .assert() + .success(); + + bin_with_root(&root) + .env("PATH", prepend_path(&fake_mutool_dir)) + .args(["ownership", "import", "--url", &pdf_url]) + .assert() + .success() + .stdout(predicate::str::contains("Imported 1 rows for 1 tickers")); + + bin_with_root(&root) + .args(["ownership", "releases"]) + .assert() + .success() + .stdout(predicate::str::contains("2026-02-27")) + .stdout(predicate::str::contains(&pdf_url)); +} + +#[test] +fn ownership_import_url_duplicate_release_is_skipped() { + let root = test_env_dir("ownership-import-duplicate-release"); + let db_path = root.join("ownership.db"); + let fake_mutool_dir = install_fake_mutool( + &root, + include_str!("fixtures/ksei_above1_stext_excerpt.xml"), + ); + let first_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); + let second_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); + let first_url = pdf_url(&first_base, "supported-first"); + let second_url = pdf_url(&second_base, "supported-second"); + + bin_with_root(&root) + .args([ + "config", + "set", + "ownership.db_path", + db_path.to_str().unwrap(), + ]) + .assert() + .success(); + + bin_with_root(&root) + .env("PATH", prepend_path(&fake_mutool_dir)) + .args(["ownership", "import", "--url", &first_url]) + .assert() + .success(); + + bin_with_root(&root) + .env("PATH", prepend_path(&fake_mutool_dir)) + .args(["ownership", "import", "--url", &second_url]) + .assert() + .success() + .stdout(predicate::str::contains("Release already imported")); +} + +#[test] +fn ownership_import_url_rejects_legacy_above5_pdf_schema() { + let root = test_env_dir("ownership-import-above5-unsupported"); + let fake_mutool_dir = install_fake_mutool( + &root, + include_str!("fixtures/ksei_above5_stext_excerpt.xml"), + ); + let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); + let pdf_url = pdf_url(&pdf_base, "legacy-above5"); + + bin_with_root(&root) + .env("PATH", prepend_path(&fake_mutool_dir)) + .args(["ownership", "import", "--url", &pdf_url]) + .assert() + .failure() + .stderr(predicate::str::contains( + "legacy IDX `above5` ownership PDFs are not supported for import", + )); +} + +#[test] +fn ownership_import_url_rejects_legacy_investor_type_pdf_schema() { + let root = test_env_dir("ownership-import-investor-type-unsupported"); + let fake_mutool_dir = install_fake_mutool( + &root, + include_str!("fixtures/ksei_investor_type_stext_excerpt.xml"), + ); + let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); + let pdf_url = pdf_url(&pdf_base, "legacy-investor-type"); + + bin_with_root(&root) + .env("PATH", prepend_path(&fake_mutool_dir)) + .args(["ownership", "import", "--url", &pdf_url]) + .assert() + .failure() + .stderr(predicate::str::contains( + "legacy IDX `investor-type` ownership PDFs are not supported for import", + )); } #[test] diff --git a/tests/fixtures/ksei_above5_stext_excerpt.xml b/tests/fixtures/ksei_above5_stext_excerpt.xml new file mode 100644 index 0000000..6c07a82 --- /dev/null +++ b/tests/fixtures/ksei_above5_stext_excerpt.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml b/tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml new file mode 100644 index 0000000..205b325 --- /dev/null +++ b/tests/fixtures/ksei_announcement_wrapper_stext_excerpt.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/fixtures/ksei_investor_type_stext_excerpt.xml b/tests/fixtures/ksei_investor_type_stext_excerpt.xml new file mode 100644 index 0000000..ee6644b --- /dev/null +++ b/tests/fixtures/ksei_investor_type_stext_excerpt.xml @@ -0,0 +1,10 @@ + + + + + + + + + +