1pub use crate::approval::approval::v4::instance::add_cc::{
7 AddCcInstanceBodyV4, AddCcInstanceRequestV4, AddCcInstanceResponseV4,
8};
9pub use crate::approval::approval::v4::instance::detail::{
10 DetailInstanceRequestV4, DetailInstanceResponseV4, DetailInstanceTaskV4,
11};
12pub use crate::approval::approval::v4::instance::initiated::{
13 InitiatedInstanceItemV4, InitiatedInstanceRequestV4, InitiatedInstanceResponseV4,
14 InstanceSummaryV4,
15};
16pub use crate::approval::approval::v4::instance::recall::{
17 RecallInstanceBodyV4, RecallInstanceRequestV4, RecallInstanceResponseV4,
18};
19pub use crate::approval::approval::v4::instance::remind::{
20 RemindInstanceBodyV4, RemindInstanceRequestV4, RemindInstanceResponseV4,
21};
22pub use crate::approval::approval::v4::task::add_sign::{
23 AddSignTaskBodyV4, AddSignTaskRequestV4, AddSignTaskResponseV4,
24};
25pub use crate::approval::approval::v4::task::forward::{
26 ForwardTaskBodyV4, ForwardTaskRequestV4, ForwardTaskResponseV4,
27};
28pub use crate::approval::approval::v4::task::list::{
29 ListTaskItemV4, ListTaskRequestV4, ListTaskResponseV4, TaskSummaryV4,
30};
31pub use crate::approval::approval::v4::task::pass::{
32 PassTaskBodyV4, PassTaskRequestV4, PassTaskResponseV4,
33};
34pub use crate::approval::approval::v4::task::refuse::{
35 RefuseTaskBodyV4, RefuseTaskRequestV4, RefuseTaskResponseV4,
36};
37pub use crate::approval::approval::v4::task::rollback::{
38 RollbackTaskBodyV4, RollbackTaskRequestV4, RollbackTaskResponseV4,
39};
40
41use openlark_core::{SDKResult, config::Config};
42use std::sync::Arc;
43
44use crate::common::constants::MAX_PAGE_SIZE;
45
46#[derive(Debug, Clone, Default, PartialEq)]
50pub struct WorkflowTaskListQuery {
51 pub tasklist_guid: Option<String>,
53 pub section_guid: Option<String>,
55 pub filter: Option<String>,
57 pub sort: Option<serde_json::Value>,
59 pub user_type: Option<String>,
61 pub page_size: Option<i32>,
63}
64
65impl WorkflowTaskListQuery {
66 pub fn for_tasklist(tasklist_guid: impl Into<String>) -> Self {
68 Self {
69 tasklist_guid: Some(tasklist_guid.into()),
70 ..Self::default()
71 }
72 }
73
74 pub fn section_guid(mut self, section_guid: impl Into<String>) -> Self {
76 self.section_guid = Some(section_guid.into());
77 self
78 }
79
80 pub fn filter(mut self, filter: impl Into<String>) -> Self {
82 self.filter = Some(filter.into());
83 self
84 }
85
86 pub fn sort(mut self, sort: serde_json::Value) -> Self {
88 self.sort = Some(sort);
89 self
90 }
91
92 pub fn user_type(mut self, user_type: impl Into<String>) -> Self {
94 self.user_type = Some(user_type.into());
95 self
96 }
97
98 pub fn page_size(mut self, page_size: i32) -> Self {
100 self.page_size = Some(page_size);
101 self
102 }
103}
104
105#[derive(Debug, Clone, PartialEq)]
110pub struct WorkflowTaskCreate {
111 pub summary: String,
113 pub description: Option<String>,
115 pub start: Option<String>,
117 pub due: Option<String>,
119 pub priority: Option<i32>,
121 pub assignee: Option<String>,
123 pub tasklist_guid: Option<String>,
125 pub section_guid: Option<String>,
127 pub followers: Option<Vec<String>>,
129 pub remind_time: Option<String>,
131}
132
133impl WorkflowTaskCreate {
134 pub fn new(summary: impl Into<String>) -> Self {
136 Self {
137 summary: summary.into(),
138 description: None,
139 start: None,
140 due: None,
141 priority: None,
142 assignee: None,
143 tasklist_guid: None,
144 section_guid: None,
145 followers: None,
146 remind_time: None,
147 }
148 }
149
150 pub fn description(mut self, description: impl Into<String>) -> Self {
152 self.description = Some(description.into());
153 self
154 }
155
156 pub fn start(mut self, start: impl Into<String>) -> Self {
158 self.start = Some(start.into());
159 self
160 }
161
162 pub fn due(mut self, due: impl Into<String>) -> Self {
164 self.due = Some(due.into());
165 self
166 }
167
168 pub fn priority(mut self, priority: i32) -> Self {
170 self.priority = Some(priority);
171 self
172 }
173
174 pub fn assignee(mut self, assignee: impl Into<String>) -> Self {
176 self.assignee = Some(assignee.into());
177 self
178 }
179
180 pub fn tasklist_guid(mut self, tasklist_guid: impl Into<String>) -> Self {
182 self.tasklist_guid = Some(tasklist_guid.into());
183 self
184 }
185
186 pub fn section_guid(mut self, section_guid: impl Into<String>) -> Self {
188 self.section_guid = Some(section_guid.into());
189 self
190 }
191
192 pub fn followers(mut self, followers: Vec<String>) -> Self {
194 self.followers = Some(followers);
195 self
196 }
197
198 pub fn remind_time(mut self, remind_time: impl Into<String>) -> Self {
200 self.remind_time = Some(remind_time.into());
201 self
202 }
203}
204
205#[derive(Debug, Clone, Default, PartialEq)]
209pub struct WorkflowTaskMutation {
210 pub summary: Option<String>,
212 pub description: Option<String>,
214 pub due: Option<String>,
216 pub priority: Option<i32>,
218 pub assignee: Option<String>,
220 pub status: Option<String>,
222}
223
224impl WorkflowTaskMutation {
225 pub fn new() -> Self {
227 Self::default()
228 }
229
230 pub fn summary(mut self, summary: impl Into<String>) -> Self {
232 self.summary = Some(summary.into());
233 self
234 }
235
236 pub fn description(mut self, description: impl Into<String>) -> Self {
238 self.description = Some(description.into());
239 self
240 }
241
242 pub fn due(mut self, due: impl Into<String>) -> Self {
244 self.due = Some(due.into());
245 self
246 }
247
248 pub fn priority(mut self, priority: i32) -> Self {
250 self.priority = Some(priority);
251 self
252 }
253
254 pub fn assignee(mut self, assignee: impl Into<String>) -> Self {
256 self.assignee = Some(assignee.into());
257 self
258 }
259
260 pub fn status(mut self, status: impl Into<String>) -> Self {
263 self.status = Some(status.into());
264 self
265 }
266}
267
268#[derive(Debug, Clone, Default, PartialEq)]
272pub struct ApprovalTaskQuery {
273 pub user_id: String,
275 pub topic: String,
277 pub user_id_type: Option<String>,
279 pub status: Option<String>,
281 pub instance_code: Option<String>,
283 pub page_size: Option<i32>,
285}
286
287impl ApprovalTaskQuery {
288 pub fn new(user_id: impl Into<String>, topic: impl Into<String>) -> Self {
290 Self {
291 user_id: user_id.into(),
292 topic: topic.into(),
293 ..Self::default()
294 }
295 }
296
297 pub fn user_id_type(mut self, user_id_type: impl Into<String>) -> Self {
299 self.user_id_type = Some(user_id_type.into());
300 self
301 }
302
303 pub fn status(mut self, status: impl Into<String>) -> Self {
306 self.status = Some(status.into());
307 self
308 }
309
310 pub fn instance_code(mut self, instance_code: impl Into<String>) -> Self {
312 self.instance_code = Some(instance_code.into());
313 self
314 }
315
316 pub fn page_size(mut self, page_size: i32) -> Self {
318 self.page_size = Some(page_size);
319 self
320 }
321}
322
323#[derive(Debug, Clone, Default, PartialEq)]
327pub struct ApprovalTaskAction {
328 pub approval_code: String,
330 pub instance_code: String,
332 pub user_id: String,
334 pub task_id: String,
336 pub user_id_type: Option<String>,
338 pub comment: Option<String>,
340 pub form: Option<String>,
342}
343
344impl ApprovalTaskAction {
345 pub fn new(
347 approval_code: impl Into<String>,
348 instance_code: impl Into<String>,
349 user_id: impl Into<String>,
350 task_id: impl Into<String>,
351 ) -> Self {
352 Self {
353 approval_code: approval_code.into(),
354 instance_code: instance_code.into(),
355 user_id: user_id.into(),
356 task_id: task_id.into(),
357 user_id_type: None,
358 comment: None,
359 form: None,
360 }
361 }
362
363 pub fn comment(mut self, comment: impl Into<String>) -> Self {
365 self.comment = Some(comment.into());
366 self
367 }
368
369 pub fn user_id_type(mut self, user_id_type: impl Into<String>) -> Self {
371 self.user_id_type = Some(user_id_type.into());
372 self
373 }
374
375 pub fn form(mut self, form: impl Into<String>) -> Self {
377 self.form = Some(form.into());
378 self
379 }
380}
381
382pub type ApprovalTaskItem = crate::approval::approval::v4::task::query::TaskItemV4;
384
385#[derive(Clone)]
389pub struct WorkflowService {
390 config: Arc<Config>,
391}
392
393impl WorkflowService {
394 pub fn new(config: Config) -> Self {
396 Self {
397 config: Arc::new(config),
398 }
399 }
400
401 #[cfg(feature = "v1")]
402 pub fn v1(&self) -> crate::v1::TaskV1 {
404 crate::v1::TaskV1::new(self.config.clone())
405 }
406
407 #[cfg(feature = "v2")]
408 pub fn v2(&self) -> crate::v2::TaskV2 {
410 crate::v2::TaskV2::new(self.config.clone())
411 }
412
413 #[cfg(feature = "v2")]
415 pub async fn list_tasks_all(
416 &self,
417 query: WorkflowTaskListQuery,
418 ) -> SDKResult<Vec<crate::v2::task::models::TaskItem>> {
419 use crate::v2::task::list::ListTasksRequest;
420
421 let mut items = Vec::new();
422 let mut page_token: Option<String> = None;
423
424 loop {
425 let mut request = ListTasksRequest::new(self.config.clone())
426 .page_size(query.page_size.unwrap_or(MAX_PAGE_SIZE));
427
428 if let Some(tasklist_guid) = &query.tasklist_guid {
429 request = request.tasklist_guid(tasklist_guid.clone());
430 }
431 if let Some(section_guid) = &query.section_guid {
432 request = request.section_guid(section_guid.clone());
433 }
434 if let Some(filter) = &query.filter {
435 request = request.filter(filter.clone());
436 }
437 if let Some(sort) = &query.sort {
438 request = request.sort(sort.clone());
439 }
440 if let Some(user_type) = &query.user_type {
441 request = request.user_type(user_type.clone());
442 }
443 if let Some(token) = &page_token {
444 request = request.page_token(token.clone());
445 }
446
447 let response = request.execute().await?;
448 items.extend(response.items);
449
450 if !response.has_more {
451 break;
452 }
453 page_token = response.page_token;
454 }
455
456 Ok(items)
457 }
458
459 #[cfg(feature = "v2")]
464 pub async fn create_task(
465 &self,
466 create: WorkflowTaskCreate,
467 ) -> SDKResult<crate::v2::task::models::CreateTaskResponse> {
468 use crate::v2::task::create::CreateTaskRequest;
469
470 let mut request = CreateTaskRequest::new(self.config.clone()).summary(create.summary);
471 if let Some(description) = create.description {
472 request = request.description(description);
473 }
474 if let Some(start) = create.start {
475 request = request.start(start);
476 }
477 if let Some(due) = create.due {
478 request = request.due(due);
479 }
480 if let Some(priority) = create.priority {
481 request = request.priority(priority);
482 }
483 if let Some(assignee) = create.assignee {
484 request = request.assignee(assignee);
485 }
486 if let Some(tasklist_guid) = create.tasklist_guid {
487 request = request.tasklist_guid(tasklist_guid);
488 }
489 if let Some(section_guid) = create.section_guid {
490 request = request.section_guid(section_guid);
491 }
492 if let Some(followers) = create.followers {
493 request = request.followers(followers);
494 }
495 if let Some(remind_time) = create.remind_time {
496 request = request.remind_time(remind_time);
497 }
498
499 request.execute().await
500 }
501
502 #[cfg(feature = "v2")]
504 pub async fn mutate_task(
505 &self,
506 task_guid: impl Into<String>,
507 mutation: WorkflowTaskMutation,
508 ) -> SDKResult<crate::v2::task::models::UpdateTaskResponse> {
509 use crate::v2::task::update::UpdateTaskRequest;
510
511 let mut request = UpdateTaskRequest::new(self.config.clone(), task_guid.into());
512 if let Some(summary) = mutation.summary {
513 request = request.summary(summary);
514 }
515 if let Some(description) = mutation.description {
516 request = request.description(description);
517 }
518 if let Some(due) = mutation.due {
519 request = request.due(due);
520 }
521 if let Some(priority) = mutation.priority {
522 request = request.priority(priority);
523 }
524 if let Some(assignee) = mutation.assignee {
525 request = request.assignee(assignee);
526 }
527 if let Some(status) = mutation.status {
528 request = request.status(status);
529 }
530
531 request.execute().await
532 }
533
534 #[cfg(feature = "v2")]
536 pub async fn complete_task(
537 &self,
538 task_guid: impl Into<String>,
539 ) -> SDKResult<crate::v2::task::models::CompleteTaskResponse> {
540 use crate::v2::task::complete::CompleteTaskRequest;
541
542 CompleteTaskRequest::new(self.config.clone(), task_guid.into())
543 .execute()
544 .await
545 }
546
547 #[cfg(feature = "v2")]
549 pub async fn reopen_task(
550 &self,
551 task_guid: impl Into<String>,
552 ) -> SDKResult<crate::v2::task::models::UncompleteTaskResponse> {
553 use crate::v2::task::uncomplete::UncompleteTaskRequest;
554
555 UncompleteTaskRequest::new(self.config.clone(), task_guid.into())
556 .execute()
557 .await
558 }
559
560 pub async fn query_approval_tasks(
562 &self,
563 query: ApprovalTaskQuery,
564 ) -> SDKResult<Vec<ApprovalTaskItem>> {
565 let mut items = Vec::new();
566 let mut page_token: Option<String> = None;
567
568 loop {
569 let mut request = crate::approval::approval::v4::task::query::QueryTaskRequestV4::new(
570 self.config.clone(),
571 )
572 .user_id(query.user_id.clone())
573 .topic(query.topic.clone())
574 .page_size(query.page_size.unwrap_or(MAX_PAGE_SIZE));
575
576 if let Some(user_id_type) = &query.user_id_type {
577 request = request.user_id_type(user_id_type.clone());
578 }
579 if let Some(token) = &page_token {
580 request = request.page_token(token.clone());
581 }
582
583 let response = request.execute().await?;
584 items.extend(response.tasks);
585
586 if !response.has_more.unwrap_or(false) {
587 break;
588 }
589 page_token = response.page_token;
590 }
591
592 if let Some(status) = &query.status {
593 items.retain(|item| item.status == *status);
594 }
595 if let Some(instance_code) = &query.instance_code {
596 items.retain(|item| item.instance_code == *instance_code);
597 }
598
599 Ok(items)
600 }
601
602 pub async fn approve_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
607 let mut request = crate::approval::approval::v4::task::approve::ApproveTaskRequestV4::new(
608 self.config.clone(),
609 )
610 .approval_code(action.approval_code)
611 .instance_code(action.instance_code)
612 .user_id(action.user_id)
613 .task_id(action.task_id);
614 if let Some(user_id_type) = action.user_id_type {
615 request = request.user_id_type(user_id_type);
616 }
617 if let Some(comment) = action.comment {
618 request = request.comment(comment);
619 }
620 if let Some(form) = action.form {
621 request = request.form(form);
622 }
623 request.execute().await?;
624 Ok(())
625 }
626
627 pub async fn reject_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
631 let mut request = crate::approval::approval::v4::task::reject::RejectTaskRequestV4::new(
632 self.config.clone(),
633 )
634 .approval_code(action.approval_code)
635 .instance_code(action.instance_code)
636 .user_id(action.user_id)
637 .task_id(action.task_id);
638 if let Some(user_id_type) = action.user_id_type {
639 request = request.user_id_type(user_id_type);
640 }
641 if let Some(comment) = action.comment {
642 request = request.comment(comment);
643 }
644 if let Some(form) = action.form {
645 request = request.form(form);
646 }
647 request.execute().await?;
648 Ok(())
649 }
650
651 pub async fn resubmit_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
655 let mut request =
656 crate::approval::approval::v4::task::resubmit::ResubmitTaskRequestV4::new(
657 self.config.clone(),
658 )
659 .approval_code(action.approval_code)
660 .instance_code(action.instance_code)
661 .user_id(action.user_id)
662 .task_id(action.task_id);
663 if let Some(user_id_type) = action.user_id_type {
664 request = request.user_id_type(user_id_type);
665 }
666 if let Some(comment) = action.comment {
667 request = request.comment(comment);
668 }
669 if let Some(form) = action.form {
670 request = request.form(form);
671 }
672 request.execute().await?;
673 Ok(())
674 }
675}
676
677#[cfg(test)]
678#[allow(unused_imports)]
679mod tests {
680 use super::*;
681 use serde_json::json;
682
683 #[test]
684 fn test_task_list_query_builder() {
685 let query = WorkflowTaskListQuery::for_tasklist("tasklist_123")
686 .section_guid("section_456")
687 .filter("status = incomplete")
688 .sort(json!([{"field": "due", "order": "asc"}]))
689 .user_type("open_id")
690 .page_size(50);
691
692 assert_eq!(query.tasklist_guid.as_deref(), Some("tasklist_123"));
693 assert_eq!(query.section_guid.as_deref(), Some("section_456"));
694 assert_eq!(query.filter.as_deref(), Some("status = incomplete"));
695 assert_eq!(query.user_type.as_deref(), Some("open_id"));
696 assert_eq!(query.page_size, Some(50));
697 }
698
699 #[test]
700 fn test_task_mutation_builder() {
701 let mutation = WorkflowTaskMutation::new()
702 .summary("完成项目文档")
703 .description("补齐 workflow helper")
704 .due("2026-09-30T23:59:59Z")
705 .priority(3)
706 .assignee("ou_xxx")
707 .status("in_progress");
708
709 assert_eq!(mutation.summary.as_deref(), Some("完成项目文档"));
710 assert_eq!(
711 mutation.description.as_deref(),
712 Some("补齐 workflow helper")
713 );
714 assert_eq!(mutation.due.as_deref(), Some("2026-09-30T23:59:59Z"));
715 assert_eq!(mutation.priority, Some(3));
716 assert_eq!(mutation.assignee.as_deref(), Some("ou_xxx"));
717 assert_eq!(mutation.status.as_deref(), Some("in_progress"));
718 }
719
720 #[test]
721 fn test_task_create_builder() {
722 let create = WorkflowTaskCreate::new("编写 release notes")
723 .description("补齐 0.20 create_task helper")
724 .due("2026-08-01T18:00:00Z")
725 .start("2026-07-27T09:00:00Z")
726 .priority(2)
727 .assignee("ou_owner")
728 .tasklist_guid("tasklist_abc")
729 .section_guid("section_xyz")
730 .followers(vec!["ou_follower".to_string()])
731 .remind_time("2026-07-31T09:00:00Z");
732
733 assert_eq!(create.summary, "编写 release notes");
734 assert_eq!(
735 create.description.as_deref(),
736 Some("补齐 0.20 create_task helper")
737 );
738 assert_eq!(create.due.as_deref(), Some("2026-08-01T18:00:00Z"));
739 assert_eq!(create.start.as_deref(), Some("2026-07-27T09:00:00Z"));
740 assert_eq!(create.priority, Some(2));
741 assert_eq!(create.assignee.as_deref(), Some("ou_owner"));
742 assert_eq!(create.tasklist_guid.as_deref(), Some("tasklist_abc"));
743 assert_eq!(create.section_guid.as_deref(), Some("section_xyz"));
744 assert_eq!(create.followers, Some(vec!["ou_follower".to_string()]));
745 assert_eq!(create.remind_time.as_deref(), Some("2026-07-31T09:00:00Z"));
746 }
747
748 #[test]
749 fn test_approval_task_query_builder() {
750 let query = ApprovalTaskQuery::new("ou_xxx", "1")
751 .user_id_type("open_id")
752 .status("PENDING")
753 .instance_code("instance_123")
754 .page_size(100);
755
756 assert_eq!(query.user_id, "ou_xxx");
757 assert_eq!(query.topic, "1");
758 assert_eq!(query.user_id_type.as_deref(), Some("open_id"));
759 assert_eq!(query.status.as_deref(), Some("PENDING"));
760 assert_eq!(query.instance_code.as_deref(), Some("instance_123"));
761 assert_eq!(query.page_size, Some(100));
762 }
763
764 #[test]
765 fn test_approval_task_action_builder() {
766 let action =
767 ApprovalTaskAction::new("approval_code", "instance_code", "ou_xxx", "task_123")
768 .user_id_type("open_id")
769 .comment("已确认")
770 .form("[{}]");
771
772 assert_eq!(action.approval_code, "approval_code");
773 assert_eq!(action.instance_code, "instance_code");
774 assert_eq!(action.user_id, "ou_xxx");
775 assert_eq!(action.task_id, "task_123");
776 assert_eq!(action.user_id_type.as_deref(), Some("open_id"));
777 assert_eq!(action.comment.as_deref(), Some("已确认"));
778 assert_eq!(action.form.as_deref(), Some("[{}]"));
779 }
780
781 #[cfg(feature = "v2")]
783 #[tokio::test]
784 async fn test_create_task_helper_posts_high_frequency_fields() {
785 use wiremock::matchers::{body_partial_json, method, path};
786 use wiremock::{Mock, MockServer, ResponseTemplate};
787
788 let server = MockServer::start().await;
789 Mock::given(method("POST"))
790 .and(path("/open-apis/task/v2/tasks"))
791 .and(body_partial_json(json!({
792 "summary": "编写 release notes",
793 "description": "补齐 0.20 create_task helper",
794 "priority": 2,
795 "assignee": "ou_owner",
796 "tasklist_guid": "tasklist_abc"
797 })))
798 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
799 "code": 0,
800 "msg": "success",
801 "data": {
802 "task_guid": "task_created_001",
803 "summary": "编写 release notes",
804 "description": "补齐 0.20 create_task helper",
805 "status": "todo",
806 "tasklist_guid": "tasklist_abc",
807 "section_guid": null,
808 "created_at": "2026-07-27T00:00:00Z",
809 "updated_at": "2026-07-27T00:00:00Z"
810 }
811 })))
812 .mount(&server)
813 .await;
814
815 let service = WorkflowService::new(
816 Config::builder()
817 .app_id("ci_app_id")
818 .app_secret("ci_app_secret")
819 .base_url(server.uri())
820 .enable_token_cache(false)
821 .build(),
822 );
823
824 let response = service
825 .create_task(
826 WorkflowTaskCreate::new("编写 release notes")
827 .description("补齐 0.20 create_task helper")
828 .priority(2)
829 .assignee("ou_owner")
830 .tasklist_guid("tasklist_abc"),
831 )
832 .await
833 .expect("create_task 应在飞书成功响应时返回业务结果");
834
835 assert_eq!(response.task_guid, "task_created_001");
836 assert_eq!(response.summary, "编写 release notes");
837 assert_eq!(response.status, "todo");
838 assert_eq!(response.tasklist_guid.as_deref(), Some("tasklist_abc"));
839
840 let received = server.received_requests().await.unwrap_or_default();
841 assert_eq!(received.len(), 1);
842 assert_eq!(received[0].url.path(), "/open-apis/task/v2/tasks");
843 }
844
845 #[tokio::test]
847 async fn test_approve_reject_resubmit_helpers_return_unit_on_success() {
848 use wiremock::matchers::{method, path};
849 use wiremock::{Mock, MockServer, ResponseTemplate};
850
851 let server = MockServer::start().await;
852 let paths = [
853 "/open-apis/approval/v4/tasks/approve",
854 "/open-apis/approval/v4/tasks/reject",
855 "/open-apis/approval/v4/tasks/resubmit",
856 ];
857 for p in paths {
858 Mock::given(method("POST"))
859 .and(path(p))
860 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
861 "code": 0,
862 "msg": "success",
863 "data": {}
864 })))
865 .mount(&server)
866 .await;
867 }
868
869 let service = WorkflowService::new(
870 Config::builder()
871 .app_id("ci_app_id")
872 .app_secret("ci_app_secret")
873 .base_url(server.uri())
874 .enable_token_cache(false)
875 .build(),
876 );
877
878 let action =
879 ApprovalTaskAction::new("approval_code", "instance_code", "ou_xxx", "task_123")
880 .user_id_type("open_id")
881 .comment("ok")
882 .form("[]");
883
884 service
885 .approve_task(action.clone())
886 .await
887 .expect("approve_task 应在飞书成功响应时返回 Ok(())");
888 service
889 .reject_task(action.clone())
890 .await
891 .expect("reject_task 应在飞书成功响应时返回 Ok(())");
892 service
893 .resubmit_task(action)
894 .await
895 .expect("resubmit_task 应在飞书成功响应时返回 Ok(())");
896
897 let received = server.received_requests().await.unwrap_or_default();
898 assert_eq!(
899 received.len(),
900 3,
901 "三个 helper 应各打一次飞书 approval v4 端点"
902 );
903 let hit: Vec<_> = received.iter().map(|r| r.url.path().to_string()).collect();
904 for p in paths {
905 assert!(
906 hit.iter().any(|h| h == p),
907 "missing request to {p}; got {hit:?}"
908 );
909 }
910 }
911
912 #[tokio::test]
918 async fn test_approve_task_helper_propagates_api_error() {
919 use openlark_core::error::CoreError;
920 use wiremock::matchers::{method, path};
921 use wiremock::{Mock, MockServer, ResponseTemplate};
922
923 let server = MockServer::start().await;
924 Mock::given(method("POST"))
925 .and(path("/open-apis/approval/v4/tasks/approve"))
926 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
927 "code": 99991400,
928 "msg": "invalid approval task"
929 })))
930 .mount(&server)
931 .await;
932
933 let service = WorkflowService::new(
934 Config::builder()
935 .app_id("ci_app_id")
936 .app_secret("ci_app_secret")
937 .base_url(server.uri())
938 .enable_token_cache(false)
939 .build(),
940 );
941
942 let err = service
943 .approve_task(ApprovalTaskAction::new(
944 "approval_code",
945 "instance_code",
946 "ou_xxx",
947 "task_123",
948 ))
949 .await
950 .expect_err("飞书失败响应应传播为 Err,不得伪装 Ok(())");
951 assert!(
952 matches!(err, CoreError::Validation { .. } | CoreError::Api(_)),
953 "expected Validation (missing data on non-zero code) or Api, got {err:?}"
954 );
955 let msg = err.to_string();
956 assert!(
957 msg.contains("服务器没有返回有效的数据") || msg.contains("invalid approval task"),
958 "error should surface leaf validation or Feishu msg, got: {err}"
959 );
960
961 let received = server.received_requests().await.unwrap_or_default();
962 assert_eq!(received.len(), 1);
963 assert_eq!(
964 received[0].url.path(),
965 "/open-apis/approval/v4/tasks/approve"
966 );
967 }
968}