Skip to content

Commit 1aa6f99

Browse files
author
WordClaw Agent
committed
test: cover graphql auto-submit recovery flow
1 parent 158fa14 commit 1aa6f99

File tree

1 file changed

+277
-1
lines changed

1 file changed

+277
-1
lines changed

src/graphql/tenant-isolation-graphql.test.ts

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
44
import { eq } from 'drizzle-orm';
55

66
import { db } from '../db/index.js';
7-
import { agentRunDefinitions, agentRuns, contentItems, contentTypes, domains, workflowTransitions, workflows } from '../db/schema.js';
7+
import { agentRunDefinitions, agentRuns, contentItems, contentTypes, domains, reviewTasks, workflowTransitions, workflows } from '../db/schema.js';
88
import { resolvers } from './resolvers.js';
99
import { schema } from './schema.js';
1010

@@ -606,6 +606,282 @@ describe('GraphQL Tenant Isolation', () => {
606606
await db.delete(contentItems).where(eq(contentItems.id, contentItemId));
607607
}
608608
if (transitionId) {
609+
await db.delete(reviewTasks).where(eq(reviewTasks.workflowTransitionId, transitionId));
610+
await db.delete(workflowTransitions).where(eq(workflowTransitions.id, transitionId));
611+
}
612+
if (workflowId) {
613+
await db.delete(workflows).where(eq(workflows.id, workflowId));
614+
}
615+
if (scopedTypeId) {
616+
await db.delete(contentTypes).where(eq(contentTypes.id, scopedTypeId));
617+
}
618+
}
619+
});
620+
621+
it('recovers failed auto-submit run via resume and subsequent approve', async () => {
622+
let scopedTypeId: number | null = null;
623+
let workflowId: number | null = null;
624+
let transitionId: number | null = null;
625+
let contentItemId: number | null = null;
626+
let runId: string | null = null;
627+
628+
try {
629+
const [scopedType] = await db.insert(contentTypes).values({
630+
domainId: 1,
631+
name: `GraphQL Retry Type ${Date.now()}`,
632+
slug: `graphql-retry-${Date.now()}`,
633+
schema: JSON.stringify({
634+
type: 'object',
635+
properties: { title: { type: 'string' } },
636+
required: ['title']
637+
}),
638+
basePrice: 0
639+
}).returning();
640+
scopedTypeId = scopedType.id;
641+
642+
const [item] = await db.insert(contentItems).values({
643+
domainId: 1,
644+
contentTypeId: scopedTypeId,
645+
data: JSON.stringify({ title: 'graphql-retry-item' }),
646+
status: 'draft'
647+
}).returning();
648+
contentItemId = item.id;
649+
650+
const createRunResponse = await app.inject({
651+
method: 'POST',
652+
url: '/graphql',
653+
headers: {
654+
'x-domain-id': '1'
655+
},
656+
payload: {
657+
query: `
658+
mutation CreateRun($goal: String!, $metadata: JSON) {
659+
createAgentRun(goal: $goal, runType: "review_backlog_manager", requireApproval: true, metadata: $metadata) {
660+
id
661+
status
662+
}
663+
}
664+
`,
665+
variables: {
666+
goal: `graphql-retry-run-${Date.now()}`,
667+
metadata: {
668+
contentTypeId: scopedTypeId,
669+
autoSubmitReview: true
670+
}
671+
}
672+
}
673+
});
674+
expect(createRunResponse.statusCode).toBe(200);
675+
const createRunPayload = createRunResponse.json() as {
676+
data?: {
677+
createAgentRun?: {
678+
id: string;
679+
status: string;
680+
};
681+
};
682+
};
683+
runId = createRunPayload.data?.createAgentRun?.id ?? null;
684+
expect(runId).toBeTruthy();
685+
expect(createRunPayload.data?.createAgentRun?.status).toBe('waiting_approval');
686+
687+
const initialApproveResponse = await app.inject({
688+
method: 'POST',
689+
url: '/graphql',
690+
headers: {
691+
'x-domain-id': '1'
692+
},
693+
payload: {
694+
query: `
695+
mutation ControlRun($id: ID!, $action: String!) {
696+
controlAgentRun(id: $id, action: $action) {
697+
id
698+
status
699+
}
700+
}
701+
`,
702+
variables: {
703+
id: runId,
704+
action: 'approve'
705+
}
706+
}
707+
});
708+
expect(initialApproveResponse.statusCode).toBe(200);
709+
const initialApprovePayload = initialApproveResponse.json() as {
710+
data?: {
711+
controlAgentRun?: {
712+
status: string;
713+
};
714+
};
715+
};
716+
expect(initialApprovePayload.data?.controlAgentRun?.status).toBe('failed');
717+
718+
const [workflow] = await db.insert(workflows).values({
719+
domainId: 1,
720+
name: `GraphQL Retry Workflow ${Date.now()}`,
721+
contentTypeId: scopedTypeId,
722+
active: true
723+
}).returning();
724+
workflowId = workflow.id;
725+
726+
const [transition] = await db.insert(workflowTransitions).values({
727+
workflowId: workflow.id,
728+
fromState: 'draft',
729+
toState: 'pending_review',
730+
requiredRoles: []
731+
}).returning();
732+
transitionId = transition.id;
733+
734+
const resumeRunResponse = await app.inject({
735+
method: 'POST',
736+
url: '/graphql',
737+
headers: {
738+
'x-domain-id': '1'
739+
},
740+
payload: {
741+
query: `
742+
mutation ControlRun($id: ID!, $action: String!) {
743+
controlAgentRun(id: $id, action: $action) {
744+
id
745+
status
746+
}
747+
}
748+
`,
749+
variables: {
750+
id: runId,
751+
action: 'resume'
752+
}
753+
}
754+
});
755+
expect(resumeRunResponse.statusCode).toBe(200);
756+
const resumePayload = resumeRunResponse.json() as {
757+
data?: {
758+
controlAgentRun?: {
759+
status: string;
760+
};
761+
};
762+
};
763+
expect(resumePayload.data?.controlAgentRun?.status).toBe('queued');
764+
765+
const finalApproveResponse = await app.inject({
766+
method: 'POST',
767+
url: '/graphql',
768+
headers: {
769+
'x-domain-id': '1'
770+
},
771+
payload: {
772+
query: `
773+
mutation ControlRun($id: ID!, $action: String!) {
774+
controlAgentRun(id: $id, action: $action) {
775+
id
776+
status
777+
completedAt
778+
}
779+
}
780+
`,
781+
variables: {
782+
id: runId,
783+
action: 'approve'
784+
}
785+
}
786+
});
787+
expect(finalApproveResponse.statusCode).toBe(200);
788+
const finalApprovePayload = finalApproveResponse.json() as {
789+
data?: {
790+
controlAgentRun?: {
791+
status: string;
792+
completedAt: string | null;
793+
};
794+
};
795+
};
796+
expect(finalApprovePayload.data?.controlAgentRun?.status).toBe('succeeded');
797+
expect(finalApprovePayload.data?.controlAgentRun?.completedAt).not.toBeNull();
798+
799+
const runDetailsResponse = await app.inject({
800+
method: 'POST',
801+
url: '/graphql',
802+
headers: {
803+
'x-domain-id': '1'
804+
},
805+
payload: {
806+
query: `
807+
query RunDetails($id: ID!) {
808+
agentRun(id: $id) {
809+
id
810+
status
811+
steps {
812+
actionType
813+
status
814+
responseSnapshot
815+
}
816+
checkpoints {
817+
checkpointKey
818+
payload
819+
}
820+
}
821+
}
822+
`,
823+
variables: {
824+
id: runId
825+
}
826+
}
827+
});
828+
expect(runDetailsResponse.statusCode).toBe(200);
829+
const runDetailsPayload = runDetailsResponse.json() as {
830+
data?: {
831+
agentRun?: {
832+
status: string;
833+
steps: Array<{
834+
actionType: string;
835+
status: string;
836+
responseSnapshot?: {
837+
reviewTaskId?: number;
838+
workflowTransitionId?: number;
839+
} | null;
840+
}>;
841+
checkpoints: Array<{
842+
checkpointKey: string;
843+
payload?: {
844+
failedCount?: number;
845+
succeededCount?: number;
846+
settledStatus?: string;
847+
};
848+
}>;
849+
} | null;
850+
};
851+
};
852+
853+
const runDetails = runDetailsPayload.data?.agentRun;
854+
expect(runDetails?.status).toBe('succeeded');
855+
const submitSteps = (runDetails?.steps ?? []).filter((step) => step.actionType === 'submit_review');
856+
expect(submitSteps).toHaveLength(1);
857+
expect(submitSteps[0].status).toBe('succeeded');
858+
expect(submitSteps[0].responseSnapshot?.workflowTransitionId).toBe(transitionId);
859+
expect(typeof submitSteps[0].responseSnapshot?.reviewTaskId).toBe('number');
860+
861+
const retryCheckpoint = (runDetails?.checkpoints ?? []).find(
862+
(checkpoint) => checkpoint.checkpointKey === 'review_retry_scheduled'
863+
);
864+
expect(retryCheckpoint?.payload?.failedCount).toBe(1);
865+
866+
const completionCheckpoint = (runDetails?.checkpoints ?? []).find(
867+
(checkpoint) => checkpoint.checkpointKey === 'review_execution_completed'
868+
);
869+
expect(completionCheckpoint?.payload?.succeededCount).toBe(1);
870+
871+
const settledCheckpoints = (runDetails?.checkpoints ?? []).filter(
872+
(checkpoint) => checkpoint.checkpointKey === 'control_approve_settled'
873+
);
874+
expect(settledCheckpoints.some((checkpoint) => checkpoint.payload?.settledStatus === 'failed')).toBe(true);
875+
expect(settledCheckpoints.some((checkpoint) => checkpoint.payload?.settledStatus === 'succeeded')).toBe(true);
876+
} finally {
877+
if (runId) {
878+
await db.delete(agentRuns).where(eq(agentRuns.id, Number.parseInt(runId, 10)));
879+
}
880+
if (contentItemId) {
881+
await db.delete(contentItems).where(eq(contentItems.id, contentItemId));
882+
}
883+
if (transitionId) {
884+
await db.delete(reviewTasks).where(eq(reviewTasks.workflowTransitionId, transitionId));
609885
await db.delete(workflowTransitions).where(eq(workflowTransitions.id, transitionId));
610886
}
611887
if (workflowId) {

0 commit comments

Comments
 (0)