1use chrono::Duration;
7use datasynth_core::utils::seeded_rng;
8use rand::Rng;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11
12use datasynth_core::models::audit::{
13 Assertion, AuditEngagement, AuditFinding, FindingSeverity, FindingStatus, FindingType,
14 MilestoneStatus, RemediationPlan, RemediationStatus, Workpaper,
15};
16
17#[derive(Debug, Clone)]
19pub struct AvailableControl {
20 pub control_id: String,
22 pub assertions: Vec<Assertion>,
24 pub process_areas: Vec<String>,
26}
27
28#[derive(Debug, Clone)]
30pub struct AvailableRisk {
31 pub risk_id: String,
33 pub engagement_id: uuid::Uuid,
35 pub account_or_process: String,
37}
38
39#[derive(Debug, Clone)]
41pub struct FindingGeneratorConfig {
42 pub findings_per_engagement: (u32, u32),
44 pub material_weakness_probability: f64,
46 pub significant_deficiency_probability: f64,
48 pub misstatement_probability: f64,
50 pub remediation_plan_probability: f64,
52 pub management_agrees_probability: f64,
54 pub misstatement_range: (i64, i64),
56}
57
58impl Default for FindingGeneratorConfig {
59 fn default() -> Self {
60 Self {
61 findings_per_engagement: (3, 12),
62 material_weakness_probability: 0.05,
63 significant_deficiency_probability: 0.15,
64 misstatement_probability: 0.30,
65 remediation_plan_probability: 0.70,
66 management_agrees_probability: 0.85,
67 misstatement_range: (1_000, 500_000),
68 }
69 }
70}
71
72pub struct FindingGenerator {
74 rng: ChaCha8Rng,
75 config: FindingGeneratorConfig,
76 finding_counter: u32,
77 fiscal_year: u16,
78}
79
80impl FindingGenerator {
81 pub fn new(seed: u64) -> Self {
83 Self {
84 rng: seeded_rng(seed, 0),
85 config: FindingGeneratorConfig::default(),
86 finding_counter: 0,
87 fiscal_year: 2025,
88 }
89 }
90
91 pub fn with_config(seed: u64, config: FindingGeneratorConfig) -> Self {
93 Self {
94 rng: seeded_rng(seed, 0),
95 config,
96 finding_counter: 0,
97 fiscal_year: 2025,
98 }
99 }
100
101 pub fn generate_findings_for_engagement(
103 &mut self,
104 engagement: &AuditEngagement,
105 workpapers: &[Workpaper],
106 team_members: &[String],
107 ) -> Vec<AuditFinding> {
108 self.fiscal_year = engagement.fiscal_year;
109
110 let count = self.rng.random_range(
111 self.config.findings_per_engagement.0..=self.config.findings_per_engagement.1,
112 );
113
114 let mut findings = Vec::with_capacity(count as usize);
115
116 for _ in 0..count {
117 let finding = self.generate_finding(engagement, workpapers, team_members);
118 findings.push(finding);
119 }
120
121 findings
122 }
123
124 pub fn generate_finding(
126 &mut self,
127 engagement: &AuditEngagement,
128 workpapers: &[Workpaper],
129 team_members: &[String],
130 ) -> AuditFinding {
131 self.finding_counter += 1;
132
133 let finding_type = self.select_finding_type();
134 let (title, account) = self.generate_finding_title(finding_type);
135
136 let mut finding = AuditFinding::new(engagement.engagement_id, finding_type, &title);
137
138 finding.finding_ref = format!("FIND-{}-{:03}", self.fiscal_year, self.finding_counter);
139
140 let (condition, criteria, cause, effect) = self.generate_ccce(finding_type, &account);
142 finding = finding.with_details(&condition, &criteria, &cause, &effect);
143
144 let recommendation = self.generate_recommendation(finding_type, &account);
146 finding = finding.with_recommendation(&recommendation);
147
148 finding.severity = self.determine_severity(finding_type, &finding);
150
151 if self.is_misstatement_type(finding_type) {
153 let (factual, projected, judgmental) = self.generate_misstatement_amounts();
154 finding = finding.with_misstatement(factual, projected, judgmental);
155
156 if let Some(f) = factual {
157 finding = finding.with_monetary_impact(f);
158 }
159 }
160
161 finding.assertions_affected = self.select_assertions(finding_type);
163 finding.accounts_affected = vec![account.clone()];
164 finding.process_areas = self.select_process_areas(&account);
165
166 if !workpapers.is_empty() {
168 let wp_count = self.rng.random_range(1..=3.min(workpapers.len()));
169 for _ in 0..wp_count {
170 let idx = self.rng.random_range(0..workpapers.len());
171 finding.workpaper_refs.push(workpapers[idx].workpaper_id);
172 }
173 if let Some(first_wp) = finding.workpaper_refs.first() {
175 finding.workpaper_id = Some(first_wp.to_string());
176 }
177 }
178
179 let identifier = self.select_team_member(team_members, "senior");
181 finding.identified_by = identifier;
182 finding.identified_date =
183 engagement.fieldwork_start + Duration::days(self.rng.random_range(7..30));
184
185 if self.rng.random::<f64>() < 0.8 {
187 finding.reviewed_by = Some(self.select_team_member(team_members, "manager"));
188 finding.review_date =
189 Some(finding.identified_date + Duration::days(self.rng.random_range(3..10)));
190 finding.status = FindingStatus::PendingReview;
191 }
192
193 finding.mark_for_reporting(
195 finding.finding_type.requires_sox_reporting() || finding.severity.score() >= 3,
196 finding.requires_governance_communication(),
197 );
198
199 if self.rng.random::<f64>() < 0.7 {
201 let response_date =
202 finding.identified_date + Duration::days(self.rng.random_range(7..21));
203 let agrees = self.rng.random::<f64>() < self.config.management_agrees_probability;
204 let response = self.generate_management_response(finding_type, agrees);
205 finding.add_management_response(&response, agrees, response_date);
206
207 if agrees && self.rng.random::<f64>() < self.config.remediation_plan_probability {
209 let plan = self.generate_remediation_plan(&finding, &account);
210 finding.with_remediation_plan(plan);
211 }
212 }
213
214 finding
215 }
216
217 pub fn generate_findings_with_context(
222 &mut self,
223 engagement: &AuditEngagement,
224 workpapers: &[Workpaper],
225 team_members: &[String],
226 controls: &[AvailableControl],
227 risks: &[AvailableRisk],
228 ) -> Vec<AuditFinding> {
229 let mut findings =
230 self.generate_findings_for_engagement(engagement, workpapers, team_members);
231
232 for finding in &mut findings {
233 self.link_controls_and_risks(finding, controls, risks);
234 }
235
236 findings
237 }
238
239 fn link_controls_and_risks(
241 &mut self,
242 finding: &mut AuditFinding,
243 controls: &[AvailableControl],
244 risks: &[AvailableRisk],
245 ) {
246 let finding_assertions = &finding.assertions_affected;
248 let finding_process_areas = &finding.process_areas;
249
250 let mut matched_controls: Vec<&AvailableControl> = controls
251 .iter()
252 .filter(|ctrl| {
253 let assertion_match = ctrl
255 .assertions
256 .iter()
257 .any(|a| finding_assertions.contains(a));
258 let process_match = ctrl.process_areas.iter().any(|pa| {
260 finding_process_areas
261 .iter()
262 .any(|fp| fp.to_lowercase().contains(&pa.to_lowercase()))
263 });
264 assertion_match || process_match
265 })
266 .collect();
267
268 if matched_controls.is_empty() && !controls.is_empty() {
270 let count = self.rng.random_range(1..=2.min(controls.len()));
271 for _ in 0..count {
272 let idx = self.rng.random_range(0..controls.len());
273 matched_controls.push(&controls[idx]);
274 }
275 }
276
277 if matched_controls.len() > 3 {
279 matched_controls.truncate(3);
280 }
281
282 finding.related_control_ids = matched_controls
283 .iter()
284 .map(|c| c.control_id.clone())
285 .collect();
286
287 let engagement_risks: Vec<&AvailableRisk> = risks
289 .iter()
290 .filter(|r| r.engagement_id == finding.engagement_id)
291 .collect();
292
293 if !engagement_risks.is_empty() {
294 let matching_risk = engagement_risks.iter().find(|r| {
296 finding.accounts_affected.iter().any(|a| {
297 r.account_or_process
298 .to_lowercase()
299 .contains(&a.to_lowercase())
300 })
301 });
302
303 if let Some(risk) = matching_risk {
304 finding.related_risk_id = Some(risk.risk_id.clone());
305 } else {
306 let idx = self.rng.random_range(0..engagement_risks.len());
308 finding.related_risk_id = Some(engagement_risks[idx].risk_id.clone());
309 }
310 }
311 }
312
313 fn select_finding_type(&mut self) -> FindingType {
315 let r: f64 = self.rng.random();
316
317 if r < self.config.material_weakness_probability {
318 FindingType::MaterialWeakness
319 } else if r < self.config.material_weakness_probability
320 + self.config.significant_deficiency_probability
321 {
322 FindingType::SignificantDeficiency
323 } else if r < self.config.material_weakness_probability
324 + self.config.significant_deficiency_probability
325 + self.config.misstatement_probability
326 {
327 if self.rng.random::<f64>() < 0.3 {
328 FindingType::MaterialMisstatement
329 } else {
330 FindingType::ImmaterialMisstatement
331 }
332 } else {
333 let other_types = [
334 FindingType::ControlDeficiency,
335 FindingType::ComplianceException,
336 FindingType::OtherMatter,
337 FindingType::ItDeficiency,
338 FindingType::ProcessImprovement,
339 ];
340 let idx = self.rng.random_range(0..other_types.len());
341 other_types[idx]
342 }
343 }
344
345 fn generate_finding_title(&mut self, finding_type: FindingType) -> (String, String) {
347 match finding_type {
348 FindingType::MaterialWeakness => {
349 let titles = [
350 (
351 "Inadequate segregation of duties in revenue cycle",
352 "Revenue",
353 ),
354 (
355 "Lack of effective review of journal entries",
356 "General Ledger",
357 ),
358 (
359 "Insufficient IT general controls over financial applications",
360 "IT Controls",
361 ),
362 (
363 "Inadequate controls over financial close process",
364 "Financial Close",
365 ),
366 ];
367 let idx = self.rng.random_range(0..titles.len());
368 (titles[idx].0.into(), titles[idx].1.into())
369 }
370 FindingType::SignificantDeficiency => {
371 let titles = [
372 (
373 "Inadequate documentation of account reconciliations",
374 "Accounts Receivable",
375 ),
376 (
377 "Untimely review of vendor master file changes",
378 "Accounts Payable",
379 ),
380 ("Incomplete fixed asset physical inventory", "Fixed Assets"),
381 (
382 "Lack of formal approval for manual journal entries",
383 "General Ledger",
384 ),
385 ];
386 let idx = self.rng.random_range(0..titles.len());
387 (titles[idx].0.into(), titles[idx].1.into())
388 }
389 FindingType::ControlDeficiency => {
390 let titles = [
391 (
392 "Missing secondary approval on expense reports",
393 "Operating Expenses",
394 ),
395 ("Incomplete access review documentation", "IT Controls"),
396 ("Delayed bank reconciliation preparation", "Cash"),
397 ("Inconsistent inventory count procedures", "Inventory"),
398 ];
399 let idx = self.rng.random_range(0..titles.len());
400 (titles[idx].0.into(), titles[idx].1.into())
401 }
402 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
403 let titles = [
404 ("Revenue cutoff error", "Revenue"),
405 ("Inventory valuation adjustment", "Inventory"),
406 (
407 "Accounts receivable allowance understatement",
408 "Accounts Receivable",
409 ),
410 ("Accrued liabilities understatement", "Accrued Liabilities"),
411 ("Fixed asset depreciation calculation error", "Fixed Assets"),
412 ];
413 let idx = self.rng.random_range(0..titles.len());
414 (titles[idx].0.into(), titles[idx].1.into())
415 }
416 FindingType::ComplianceException => {
417 let titles = [
418 ("Late filing of sales tax returns", "Tax"),
419 ("Incomplete Form 1099 reporting", "Tax"),
420 ("Non-compliance with debt covenant reporting", "Debt"),
421 ];
422 let idx = self.rng.random_range(0..titles.len());
423 (titles[idx].0.into(), titles[idx].1.into())
424 }
425 FindingType::ItDeficiency => {
426 let titles = [
427 ("Excessive user access privileges", "IT Controls"),
428 ("Inadequate password policy enforcement", "IT Controls"),
429 ("Missing change management documentation", "IT Controls"),
430 ("Incomplete disaster recovery testing", "IT Controls"),
431 ];
432 let idx = self.rng.random_range(0..titles.len());
433 (titles[idx].0.into(), titles[idx].1.into())
434 }
435 FindingType::OtherMatter | FindingType::ProcessImprovement => {
436 let titles = [
437 (
438 "Opportunity to improve month-end close efficiency",
439 "Financial Close",
440 ),
441 (
442 "Enhancement to vendor onboarding process",
443 "Accounts Payable",
444 ),
445 (
446 "Automation opportunity in reconciliation process",
447 "General Ledger",
448 ),
449 ];
450 let idx = self.rng.random_range(0..titles.len());
451 (titles[idx].0.into(), titles[idx].1.into())
452 }
453 }
454 }
455
456 fn generate_ccce(
458 &mut self,
459 finding_type: FindingType,
460 account: &str,
461 ) -> (String, String, String, String) {
462 match finding_type {
463 FindingType::MaterialWeakness
464 | FindingType::SignificantDeficiency
465 | FindingType::ControlDeficiency => {
466 let condition = format!(
467 "During our testing of {} controls, we noted that the control was not operating effectively. \
468 Specifically, {} of {} items tested did not have evidence of the required control activity.",
469 account,
470 self.rng.random_range(2..8),
471 self.rng.random_range(20..40)
472 );
473 let criteria = format!(
474 "Company policy and SOX requirements mandate that all {account} transactions receive appropriate \
475 review and approval prior to processing."
476 );
477 let cause = "Staffing constraints and competing priorities resulted in reduced focus on control execution.".into();
478 let effect = format!(
479 "Transactions may be processed without appropriate oversight, increasing the risk of errors \
480 or fraud in the {account} balance."
481 );
482 (condition, criteria, cause, effect)
483 }
484 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
485 let amount = self.rng.random_range(
486 self.config.misstatement_range.0..self.config.misstatement_range.1,
487 );
488 let condition = format!(
489 "Our testing identified a misstatement in {account} of approximately ${amount}. \
490 The error resulted from incorrect application of accounting standards."
491 );
492 let criteria = "US GAAP and company accounting policy require accurate recording of all transactions.".into();
493 let cause =
494 "Manual calculation error combined with inadequate review procedures.".into();
495 let effect = format!(
496 "The {} balance was {} by ${}, which {}.",
497 account,
498 if self.rng.random::<bool>() {
499 "overstated"
500 } else {
501 "understated"
502 },
503 amount,
504 if finding_type == FindingType::MaterialMisstatement {
505 "represents a material misstatement"
506 } else {
507 "is below materiality but has been communicated to management"
508 }
509 );
510 (condition, criteria, cause, effect)
511 }
512 FindingType::ComplianceException => {
513 let condition = format!(
514 "The Company did not comply with {account} regulatory requirements during the period under audit."
515 );
516 let criteria =
517 "Applicable laws and regulations require timely and accurate compliance."
518 .into();
519 let cause = "Lack of monitoring procedures to track compliance deadlines.".into();
520 let effect =
521 "The Company may be subject to penalties or regulatory scrutiny.".into();
522 (condition, criteria, cause, effect)
523 }
524 _ => {
525 let condition =
526 format!("We identified an opportunity to enhance the {account} process.");
527 let criteria =
528 "Industry best practices suggest continuous improvement in control processes."
529 .into();
530 let cause =
531 "Current processes have not been updated to reflect operational changes."
532 .into();
533 let effect =
534 "Operational efficiency could be improved with process enhancements.".into();
535 (condition, criteria, cause, effect)
536 }
537 }
538 }
539
540 fn generate_recommendation(&mut self, finding_type: FindingType, account: &str) -> String {
542 match finding_type {
543 FindingType::MaterialWeakness | FindingType::SignificantDeficiency => {
544 format!(
545 "We recommend that management: (1) Implement additional review procedures for {account} transactions, \
546 (2) Document all control activities contemporaneously, and \
547 (3) Provide additional training to personnel responsible for control execution."
548 )
549 }
550 FindingType::ControlDeficiency => {
551 format!(
552 "We recommend that management strengthen the {account} control by ensuring timely execution \
553 and documentation of all required review activities."
554 )
555 }
556 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
557 "We recommend that management record the proposed adjusting entry and implement \
558 additional review procedures to prevent similar errors in future periods.".into()
559 }
560 FindingType::ComplianceException => {
561 "We recommend that management implement a compliance calendar with automated reminders \
562 and establish monitoring procedures to ensure timely compliance.".into()
563 }
564 FindingType::ItDeficiency => {
565 "We recommend that IT management review and remediate the identified access control \
566 weaknesses and implement periodic access certification procedures.".into()
567 }
568 _ => {
569 format!(
570 "We recommend that management evaluate the {account} process for potential \
571 efficiency improvements and implement changes as appropriate."
572 )
573 }
574 }
575 }
576
577 fn determine_severity(
579 &mut self,
580 finding_type: FindingType,
581 _finding: &AuditFinding,
582 ) -> FindingSeverity {
583 let base_severity = finding_type.default_severity();
584
585 if self.rng.random::<f64>() < 0.2 {
587 match base_severity {
588 FindingSeverity::Critical => FindingSeverity::High,
589 FindingSeverity::High => {
590 if self.rng.random::<bool>() {
591 FindingSeverity::Critical
592 } else {
593 FindingSeverity::Medium
594 }
595 }
596 FindingSeverity::Medium => {
597 if self.rng.random::<bool>() {
598 FindingSeverity::High
599 } else {
600 FindingSeverity::Low
601 }
602 }
603 FindingSeverity::Low => FindingSeverity::Medium,
604 FindingSeverity::Informational => FindingSeverity::Low,
605 }
606 } else {
607 base_severity
608 }
609 }
610
611 fn is_misstatement_type(&self, finding_type: FindingType) -> bool {
613 matches!(
614 finding_type,
615 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement
616 )
617 }
618
619 fn generate_misstatement_amounts(
621 &mut self,
622 ) -> (Option<Decimal>, Option<Decimal>, Option<Decimal>) {
623 let factual = Decimal::new(
624 self.rng
625 .random_range(self.config.misstatement_range.0..self.config.misstatement_range.1),
626 0,
627 );
628
629 let projected = if self.rng.random::<f64>() < 0.5 {
630 Some(Decimal::new(
631 self.rng
632 .random_range(0..self.config.misstatement_range.1 / 2),
633 0,
634 ))
635 } else {
636 None
637 };
638
639 let judgmental = if self.rng.random::<f64>() < 0.3 {
640 Some(Decimal::new(
641 self.rng
642 .random_range(0..self.config.misstatement_range.1 / 4),
643 0,
644 ))
645 } else {
646 None
647 };
648
649 (Some(factual), projected, judgmental)
650 }
651
652 fn select_assertions(&mut self, finding_type: FindingType) -> Vec<Assertion> {
654 let mut assertions = Vec::new();
655
656 match finding_type {
657 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
658 assertions.push(Assertion::Accuracy);
659 if self.rng.random::<bool>() {
660 assertions.push(Assertion::ValuationAndAllocation);
661 }
662 }
663 FindingType::MaterialWeakness
664 | FindingType::SignificantDeficiency
665 | FindingType::ControlDeficiency => {
666 let possible = [
667 Assertion::Occurrence,
668 Assertion::Completeness,
669 Assertion::Accuracy,
670 Assertion::Classification,
671 ];
672 let count = self.rng.random_range(1..=3);
673 for _ in 0..count {
674 let idx = self.rng.random_range(0..possible.len());
675 if !assertions.contains(&possible[idx]) {
676 assertions.push(possible[idx]);
677 }
678 }
679 }
680 _ => {
681 assertions.push(Assertion::PresentationAndDisclosure);
682 }
683 }
684
685 assertions
686 }
687
688 fn select_process_areas(&mut self, account: &str) -> Vec<String> {
690 let account_lower = account.to_lowercase();
691
692 if account_lower.contains("revenue") || account_lower.contains("receivable") {
693 vec!["Order to Cash".into(), "Revenue Recognition".into()]
694 } else if account_lower.contains("payable") || account_lower.contains("expense") {
695 vec!["Procure to Pay".into(), "Expense Management".into()]
696 } else if account_lower.contains("inventory") {
697 vec!["Inventory Management".into(), "Cost of Goods Sold".into()]
698 } else if account_lower.contains("fixed asset") {
699 vec!["Capital Asset Management".into()]
700 } else if account_lower.contains("it") {
701 vec![
702 "IT General Controls".into(),
703 "IT Application Controls".into(),
704 ]
705 } else if account_lower.contains("payroll") {
706 vec!["Hire to Retire".into(), "Payroll Processing".into()]
707 } else {
708 vec!["Financial Close".into()]
709 }
710 }
711
712 fn generate_management_response(&mut self, finding_type: FindingType, agrees: bool) -> String {
714 if agrees {
715 match finding_type {
716 FindingType::MaterialWeakness | FindingType::SignificantDeficiency => {
717 "Management agrees with the finding and has initiated a remediation plan to \
718 address the identified control deficiency. We expect to complete remediation \
719 prior to the next audit cycle."
720 .into()
721 }
722 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
723 "Management agrees with the proposed adjustment and will record the entry. \
724 We have implemented additional review procedures to prevent similar errors."
725 .into()
726 }
727 _ => "Management agrees with the observation and will implement the recommended \
728 improvements as resources permit."
729 .into(),
730 }
731 } else {
732 "Management respectfully disagrees with the finding. We believe that existing \
733 controls are adequate and operating effectively. We will provide additional \
734 documentation to support our position."
735 .into()
736 }
737 }
738
739 fn generate_remediation_plan(
741 &mut self,
742 finding: &AuditFinding,
743 account: &str,
744 ) -> RemediationPlan {
745 let target_date = finding.identified_date + Duration::days(self.rng.random_range(60..180));
746
747 let description = format!(
748 "Implement enhanced controls and monitoring procedures for {account} to address \
749 the identified deficiency. This includes updated policies, additional training, \
750 and implementation of automated controls where feasible."
751 );
752
753 let responsible_party = format!(
754 "{} Manager",
755 if account.to_lowercase().contains("it") {
756 "IT"
757 } else {
758 "Controller"
759 }
760 );
761
762 let mut plan = RemediationPlan::new(
763 finding.finding_id,
764 &description,
765 &responsible_party,
766 target_date,
767 );
768
769 plan.validation_approach =
770 "Auditor will test remediated controls during the next audit cycle.".into();
771
772 let milestone_dates = [
774 (
775 finding.identified_date + Duration::days(30),
776 "Complete root cause analysis",
777 ),
778 (
779 finding.identified_date + Duration::days(60),
780 "Document updated control procedures",
781 ),
782 (
783 finding.identified_date + Duration::days(90),
784 "Implement control changes",
785 ),
786 (target_date, "Complete testing and validation"),
787 ];
788
789 for (date, desc) in milestone_dates {
790 plan.add_milestone(desc, date);
791 }
792
793 if self.rng.random::<f64>() < 0.3 {
795 plan.status = RemediationStatus::InProgress;
796 if !plan.milestones.is_empty() {
797 plan.milestones[0].status = MilestoneStatus::Complete;
798 plan.milestones[0].completion_date = Some(plan.milestones[0].target_date);
799 }
800 }
801
802 plan
803 }
804
805 fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
807 let matching: Vec<&String> = team_members
808 .iter()
809 .filter(|m| m.to_lowercase().contains(role_hint))
810 .collect();
811
812 if let Some(&member) = matching.first() {
813 member.clone()
814 } else if !team_members.is_empty() {
815 let idx = self.rng.random_range(0..team_members.len());
816 team_members[idx].clone()
817 } else {
818 format!("{}001", role_hint.to_uppercase())
819 }
820 }
821}
822
823#[cfg(test)]
824#[allow(clippy::unwrap_used)]
825mod tests {
826 use super::*;
827 use crate::audit::test_helpers::create_test_engagement;
828
829 #[test]
830 fn test_finding_generation() {
831 let mut generator = FindingGenerator::new(42);
832 let engagement = create_test_engagement();
833 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
834
835 let findings = generator.generate_findings_for_engagement(&engagement, &[], &team);
836
837 assert!(!findings.is_empty());
838 for finding in &findings {
839 assert!(!finding.condition.is_empty());
840 assert!(!finding.criteria.is_empty());
841 assert!(!finding.recommendation.is_empty());
842 }
843 }
844
845 #[test]
846 fn test_finding_types_distribution() {
847 let mut generator = FindingGenerator::new(42);
848 let engagement = create_test_engagement();
849 let team = vec!["STAFF001".into()];
850
851 let config = FindingGeneratorConfig {
853 findings_per_engagement: (50, 50),
854 ..Default::default()
855 };
856 generator.config = config;
857
858 let findings = generator.generate_findings_for_engagement(&engagement, &[], &team);
859
860 let material_weaknesses = findings
861 .iter()
862 .filter(|f| f.finding_type == FindingType::MaterialWeakness)
863 .count();
864 let significant_deficiencies = findings
865 .iter()
866 .filter(|f| f.finding_type == FindingType::SignificantDeficiency)
867 .count();
868
869 assert!(material_weaknesses < 10);
871 assert!(significant_deficiencies > material_weaknesses);
873 }
874
875 #[test]
876 fn test_misstatement_finding() {
877 let config = FindingGeneratorConfig {
878 misstatement_probability: 1.0,
879 material_weakness_probability: 0.0,
880 significant_deficiency_probability: 0.0,
881 ..Default::default()
882 };
883 let mut generator = FindingGenerator::with_config(42, config);
884 let engagement = create_test_engagement();
885
886 let finding = generator.generate_finding(&engagement, &[], &["STAFF001".into()]);
887
888 assert!(finding.is_misstatement);
889 assert!(finding.factual_misstatement.is_some() || finding.projected_misstatement.is_some());
890 }
891
892 #[test]
893 fn test_remediation_plan() {
894 let config = FindingGeneratorConfig {
895 remediation_plan_probability: 1.0,
896 management_agrees_probability: 1.0,
897 ..Default::default()
898 };
899 let mut generator = FindingGenerator::with_config(42, config);
900 let engagement = create_test_engagement();
901
902 let findings =
903 generator.generate_findings_for_engagement(&engagement, &[], &["STAFF001".into()]);
904
905 let with_plans = findings
907 .iter()
908 .filter(|f| f.remediation_plan.is_some())
909 .count();
910 assert!(with_plans > 0);
911
912 for finding in findings.iter().filter(|f| f.remediation_plan.is_some()) {
913 let plan = finding.remediation_plan.as_ref().unwrap();
914 assert!(!plan.description.is_empty());
915 assert!(!plan.milestones.is_empty());
916 }
917 }
918
919 #[test]
920 fn test_governance_communication() {
921 let config = FindingGeneratorConfig {
922 material_weakness_probability: 1.0,
923 ..Default::default()
924 };
925 let mut generator = FindingGenerator::with_config(42, config);
926 let engagement = create_test_engagement();
927
928 let finding = generator.generate_finding(&engagement, &[], &["STAFF001".into()]);
929
930 assert!(finding.report_to_governance);
931 assert!(finding.include_in_management_letter);
932 }
933
934 #[test]
935 fn test_generate_findings_with_context_links_controls_and_risks() {
936 use datasynth_core::models::audit::Assertion;
937
938 let mut generator = FindingGenerator::new(42);
939 let engagement = create_test_engagement();
940 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
941
942 let controls = vec![
943 AvailableControl {
944 control_id: "CTRL-001".into(),
945 assertions: vec![Assertion::Accuracy, Assertion::Completeness],
946 process_areas: vec!["Revenue Recognition".into()],
947 },
948 AvailableControl {
949 control_id: "CTRL-002".into(),
950 assertions: vec![Assertion::Occurrence],
951 process_areas: vec!["Procure to Pay".into()],
952 },
953 ];
954
955 let risks = vec![AvailableRisk {
956 risk_id: "RISK-001".into(),
957 engagement_id: engagement.engagement_id,
958 account_or_process: "Revenue".into(),
959 }];
960
961 let findings =
962 generator.generate_findings_with_context(&engagement, &[], &team, &controls, &risks);
963
964 assert!(!findings.is_empty());
965
966 for finding in &findings {
968 let has_controls = !finding.related_control_ids.is_empty();
969 let has_risk = finding.related_risk_id.is_some();
970 assert!(
971 has_controls || has_risk,
972 "Finding {} should have related controls or risk",
973 finding.finding_ref
974 );
975 }
976
977 let with_risk = findings
979 .iter()
980 .filter(|f| f.related_risk_id.is_some())
981 .count();
982 assert!(with_risk > 0, "At least one finding should link to a risk");
983 }
984
985 #[test]
986 fn test_generate_findings_with_context_caps_controls_at_three() {
987 use datasynth_core::models::audit::Assertion;
988
989 let config = FindingGeneratorConfig {
990 findings_per_engagement: (5, 5),
991 ..Default::default()
992 };
993 let mut generator = FindingGenerator::with_config(42, config);
994 let engagement = create_test_engagement();
995 let team = vec!["STAFF001".into()];
996
997 let controls: Vec<AvailableControl> = (0..10)
999 .map(|i| AvailableControl {
1000 control_id: format!("CTRL-{:03}", i),
1001 assertions: vec![
1002 Assertion::Accuracy,
1003 Assertion::Completeness,
1004 Assertion::Occurrence,
1005 Assertion::Classification,
1006 ],
1007 process_areas: vec![
1008 "Revenue Recognition".into(),
1009 "Procure to Pay".into(),
1010 "Financial Close".into(),
1011 ],
1012 })
1013 .collect();
1014
1015 let findings =
1016 generator.generate_findings_with_context(&engagement, &[], &team, &controls, &[]);
1017
1018 for finding in &findings {
1019 assert!(
1020 finding.related_control_ids.len() <= 3,
1021 "Finding {} has {} controls, expected max 3",
1022 finding.finding_ref,
1023 finding.related_control_ids.len()
1024 );
1025 }
1026 }
1027
1028 #[test]
1029 fn test_workpaper_id_populated_from_workpaper_refs() {
1030 let mut generator = FindingGenerator::new(42);
1031 let engagement = create_test_engagement();
1032 let team = vec!["STAFF001".into()];
1033
1034 use datasynth_core::models::audit::{Workpaper, WorkpaperSection};
1036 let workpaper = Workpaper::new(
1037 engagement.engagement_id,
1038 "WP-001",
1039 "Test Workpaper",
1040 WorkpaperSection::ControlTesting,
1041 );
1042
1043 let findings = generator.generate_findings_for_engagement(&engagement, &[workpaper], &team);
1044
1045 for finding in &findings {
1047 assert!(
1048 !finding.workpaper_refs.is_empty(),
1049 "Finding should have workpaper refs when workpapers provided"
1050 );
1051 assert!(
1052 finding.workpaper_id.is_some(),
1053 "Finding should have workpaper_id set when workpaper_refs is populated"
1054 );
1055 }
1056 }
1057}