mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 09:43:53 +00:00
Complete batch 2 ownership import hardening
This commit is contained in:
parent
c0bcf9c688
commit
a52712b063
11 changed files with 698 additions and 62 deletions
|
|
@ -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<Option<ResolvedPdfInput>, 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<PathBuf, IdxError> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<Vec<KseiRawRow>, IdxError> {
|
||||
/// Extract mutool stext XML from a PDF file.
|
||||
pub fn extract_pdf_stext(path: &Path) -> Result<String, IdxError> {
|
||||
check_mutool()?;
|
||||
|
||||
let output = Command::new("mutool")
|
||||
|
|
@ -59,6 +101,35 @@ pub fn parse_ksei_pdf(path: &Path) -> Result<Vec<KseiRawRow>, 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<Vec<KseiRawRow>, 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() {
|
||||
|
|
|
|||
|
|
@ -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<AnnouncementPage, IdxError>
|
|||
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("<!doctype html") || normalized.starts_with("<html") {
|
||||
return Err(IdxError::Http(
|
||||
"IDX ownership discovery returned HTML instead of announcement JSON".to_string(),
|
||||
"IDX ownership discovery returned HTML instead of announcement JSON; verify the announcement endpoint or try again later".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +199,8 @@ pub fn select_latest_ownership_reports(
|
|||
.find(|query| query.family == family)
|
||||
else {
|
||||
return Err(IdxError::Http(format!(
|
||||
"failed to discover IDX ownership reports from {query_url}: unknown report family"
|
||||
"failed to discover {} IDX ownership reports from {query_url}: unknown report family",
|
||||
family.cli_name()
|
||||
)));
|
||||
};
|
||||
|
||||
|
|
@ -185,7 +213,8 @@ pub fn select_latest_ownership_reports(
|
|||
|
||||
let Some(item) = matches.into_iter().next() else {
|
||||
return Err(IdxError::Http(format!(
|
||||
"failed to discover IDX ownership reports from {query_url}: no matching announcement found"
|
||||
"failed to discover {} IDX ownership reports from {query_url}: no matching announcement found",
|
||||
family.cli_name()
|
||||
)));
|
||||
};
|
||||
|
||||
|
|
@ -198,9 +227,11 @@ pub fn select_latest_ownership_reports(
|
|||
.clone()
|
||||
.or(attachment.pdf_filename.clone())
|
||||
.map(|value| value.trim().to_string());
|
||||
let status = classify_report_status(family, is_attachment);
|
||||
|
||||
DiscoveredOwnershipPdf {
|
||||
family,
|
||||
status,
|
||||
listing_page_url: announcement_listing_url(),
|
||||
query_url: query_url.to_string(),
|
||||
pdf_url: attachment.full_save_path,
|
||||
|
|
@ -214,14 +245,17 @@ pub fn select_latest_ownership_reports(
|
|||
.collect::<Vec<_>>();
|
||||
|
||||
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("<!doctype html") || preview.contains("<html") {
|
||||
return Err(IdxError::Http(
|
||||
"IDX ownership download returned HTML instead of a PDF".to_string(),
|
||||
"IDX ownership download returned HTML instead of a PDF/direct attachment".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Err(IdxError::Http(
|
||||
"IDX ownership download did not look like a PDF".to_string(),
|
||||
"IDX ownership download did not look like a PDF/direct attachment".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -415,6 +450,17 @@ fn attachment_label(attachment: &AnnouncementAttachment) -> 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<Vec<AnnouncementAttachment>> {
|
||||
serde_json::from_str::<Vec<AnnouncementAttachment>>(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"<!doctype html><html><body>blocked</body></html>")
|
||||
.expect_err("html body must fail");
|
||||
assert!(matches!(err, IdxError::Http(_)));
|
||||
assert!(err.to_string().contains("PDF/direct attachment"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue