History

Test Google per-service add flow

Rhys Sullivan committed 4 months ago

Commit 5f33dba1 parent 9b819781 file · +280 −17Browse files

Unified · Split

packages/plugins/google/src/sdk/plugin.test.ts+280−17
@@ -431,28 +431,59 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 430 unmodified lines
431431 [OAUTH2_URL]: toJson(oauth2Doc),
432432 };
433433
434+interface DiscoveryHttpClientOptions {
435+ readonly failedUrls?: ReadonlySet<string>;
436+ readonly beforeResponse?: (url: string) => Effect.Effect<void, never, never>;
437+}
438+
439+const waitOneTurn: Effect.Effect<void, never, never> = Effect.promise(() => Promise.resolve()).pipe(
440+ Effect.orDie,
441+);
442+
434443 // A stub HTTP client that serves the canned Discovery document for whichever
435444 // URL the bundle converter fetches. Service-hosted Discovery URLs carry their
436445 // version in the query string, so match the full URL before falling back to the
437446 // path-only key used by central Discovery URLs.
438−const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
439− HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
440− const url = new URL(request.url);
441− const key = `${url.origin}${url.pathname}`;
442− const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key];
443− return Effect.succeed(
444− HttpClientResponse.fromWeb(
447+const makeDiscoveryHttpClientLayer = (options: DiscoveryHttpClientOptions = {}) =>
448+ Layer.succeed(HttpClient.HttpClient)(
449+ HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
450+ const url = new URL(request.url);
451+ const key = `${url.origin}${url.pathname}`;
452+ const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key];
453+ const discoveryUrl = DISCOVERY_BODIES[url.toString()] !== undefined ? url.toString() : key;
454+ const failed =
455+ options.failedUrls?.has(url.toString()) === true || options.failedUrls?.has(key) === true;
456+ const response = HttpClientResponse.fromWeb(
445457 request,
446− body === undefined
447− ? new Response("not found", { status: 404 })
448− : new Response(body, {
449− status: 200,
450− headers: { "content-type": "application/json" },
451− }),
452− ),
453− );
454− }),
455−);
458+ failed
459+ ? new Response("forced failure", { status: 503 })
460+ : body === undefined
461+ ? new Response("not found", { status: 404 })
462+ : new Response(body, {
463+ status: 200,
464+ headers: { "content-type": "application/json" },
465+ }),
466+ );
467+ return (options.beforeResponse?.(discoveryUrl) ?? Effect.void).pipe(Effect.as(response));
468+ }),
469+ );
470+
471+const discoveryHttpClientLayer = makeDiscoveryHttpClientLayer();
472+
473+const oauthScopesFromConfig = (
474+ config: {
475+ readonly authenticationTemplate?: readonly {
476+ readonly kind: string;
477+ readonly scopes?: readonly string[];
478+ }[];
479+ } | null,
480+): readonly string[] | undefined => {
481+ const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
482+ return oauth?.scopes ? [...oauth.scopes].sort() : undefined;
483+};
484+
485+const googleDiscoveryToolNames = (toolName: string): readonly string[] =>
486+ [toolName, "oauth2.tokeninfo", "oauth2.userinfo.get", "oauth2.userinfo.v2.me.get"].sort();
456487
457488 const bundlePlugins = () =>
458489 [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const;
@@ -701,6 +732,238 @@ describe("Google bundle add flow", () => { · 242 unmodified lines
701732 );
702733 });
703734
735+describe("Google per-service add flow", () => {
736+ const serviceExpectations = [
737+ {
738+ presetId: "google-calendar",
739+ slug: "google_calendar",
740+ name: "Google Calendar",
741+ description: "Calendars, events, ACLs, and scheduling.",
742+ discoveryUrl: CALENDAR_URL,
743+ scopes: ["email", "https://www.googleapis.com/auth/calendar", "openid", "profile"],
744+ toolNames: googleDiscoveryToolNames("calendar.events.list"),
745+ },
746+ {
747+ presetId: "google-gmail",
748+ slug: "google_gmail",
749+ name: "Gmail",
750+ description: "Messages, threads, labels, and drafts.",
751+ discoveryUrl: GMAIL_URL,
752+ scopes: ["email", "https://mail.google.com/", "openid", "profile"],
753+ toolNames: googleDiscoveryToolNames("gmail.users.messages.list"),
754+ },
755+ {
756+ presetId: "google-drive",
757+ slug: "google_drive",
758+ name: "Google Drive",
759+ description: "Files, folders, permissions, and shared drives.",
760+ discoveryUrl: DRIVE_URL,
761+ scopes: ["email", "https://www.googleapis.com/auth/drive", "openid", "profile"],
762+ toolNames: googleDiscoveryToolNames("drive.files.list"),
763+ },
764+ ] as const;
765+
766+ it.effect(
767+ "addServices creates one integration per preset with exact scopes and health checks",
768+ () =>
769+ Effect.scoped(
770+ Effect.gen(function* () {
771+ const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
772+ const connectedToolNames = (slug: string) =>
773+ Effect.gen(function* () {
774+ yield* executor.connections.create({
775+ owner: "org",
776+ name: ConnectionName.make(slug),
777+ integration: IntegrationSlug.make(slug),
778+ template: AuthTemplateSlug.make("googleOAuth2"),
779+ value: "token-xyz",
780+ });
781+ return (yield* executor.tools.list({ integration: IntegrationSlug.make(slug) }))
782+ .map((tool) => String(tool.name))
783+ .sort();
784+ });
785+
786+ const result = yield* executor.google.addServices({
787+ services: serviceExpectations.map((service) => ({ presetId: service.presetId })),
788+ });
789+
790+ expect(result).toEqual({
791+ added: serviceExpectations.map((service) => ({
792+ slug: IntegrationSlug.make(service.slug),
793+ presetId: service.presetId,
794+ toolCount: 4,
795+ })),
796+ skipped: [],
797+ failed: [],
798+ });
799+
800+ const integrations = yield* executor.integrations.list();
801+ for (const service of serviceExpectations) {
802+ const integration = integrations.find((item) => String(item.slug) === service.slug);
803+ expect(integration?.name).toBe(service.name);
804+ expect(integration?.description).toBe(service.description);
805+
806+ const config = yield* executor.google.getConfig(service.slug);
807+ expect(config?.googleDiscoveryUrls).toEqual([service.discoveryUrl, OAUTH2_URL]);
808+ expect(oauthScopesFromConfig(config)).toEqual([...service.scopes].sort());
809+
810+ const stored = yield* executor.integrations.healthCheck.get(
811+ IntegrationSlug.make(service.slug),
812+ );
813+ expect(stored?.operation).toBe("oauth2.userinfo.get");
814+ expect(stored?.args).toBeUndefined();
815+ expect(stored?.identityField).toBe("email");
816+
817+ expect(yield* connectedToolNames(service.slug)).toEqual(service.toolNames);
818+ }
819+ }),
820+ ),
821+ );
822+
823+ it.effect(
824+ "addServices isolates a Discovery fetch failure and keeps other services registered",
825+ () =>
826+ Effect.scoped(
827+ Effect.gen(function* () {
828+ const failingLayer = makeDiscoveryHttpClientLayer({
829+ failedUrls: new Set([GMAIL_URL]),
830+ });
831+ const executor = yield* createExecutor(
832+ makeTestConfig({
833+ plugins: [googlePlugin({ httpClientLayer: failingLayer }), memoryCredentialsPlugin()],
834+ }),
835+ );
836+ const connectedToolNames = (slug: string) =>
837+ Effect.gen(function* () {
838+ yield* executor.connections.create({
839+ owner: "org",
840+ name: ConnectionName.make(slug),
841+ integration: IntegrationSlug.make(slug),
842+ template: AuthTemplateSlug.make("googleOAuth2"),
843+ value: "token-xyz",
844+ });
845+ return (yield* executor.tools.list({ integration: IntegrationSlug.make(slug) }))
846+ .map((tool) => String(tool.name))
847+ .sort();
848+ });
849+
850+ const result = yield* executor.google.addServices({
851+ services: [
852+ { presetId: "google-calendar" },
853+ { presetId: "google-gmail" },
854+ { presetId: "google-drive" },
855+ ],
856+ });
857+
858+ expect(result).toEqual({
859+ added: [
860+ {
861+ slug: IntegrationSlug.make("google_calendar"),
862+ presetId: "google-calendar",
863+ toolCount: 4,
864+ },
865+ {
866+ slug: IntegrationSlug.make("google_drive"),
867+ presetId: "google-drive",
868+ toolCount: 4,
869+ },
870+ ],
871+ skipped: [],
872+ failed: [
873+ {
874+ slug: IntegrationSlug.make("google_gmail"),
875+ presetId: "google-gmail",
876+ error: "Failed to fetch Google Discovery document: HTTP 503",
877+ },
878+ ],
879+ });
880+
881+ expect(yield* executor.google.getIntegration("google_gmail")).toBeNull();
882+ expect(yield* connectedToolNames("google_calendar")).toEqual(
883+ googleDiscoveryToolNames("calendar.events.list"),
884+ );
885+ expect(yield* connectedToolNames("google_drive")).toEqual(
886+ googleDiscoveryToolNames("drive.files.list"),
887+ );
888+ }),
889+ ),
890+ );
891+
892+ it.effect("addServices skips an existing service without duplicating integration rows", () =>
893+ Effect.scoped(
894+ Effect.gen(function* () {
895+ const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
896+
897+ yield* executor.google.addServices({
898+ services: [{ presetId: "google-calendar" }],
899+ });
900+ const result = yield* executor.google.addServices({
901+ services: [{ presetId: "google-calendar" }],
902+ });
903+
904+ expect(result).toEqual({
905+ added: [],
906+ skipped: [
907+ {
908+ slug: IntegrationSlug.make("google_calendar"),
909+ presetId: "google-calendar",
910+ reason: "already_exists",
911+ },
912+ ],
913+ failed: [],
914+ });
915+
916+ const integrations = yield* executor.integrations.list();
917+ expect(
918+ integrations.filter((integration) => String(integration.slug) === "google_calendar"),
919+ ).toHaveLength(1);
920+ }),
921+ ),
922+ );
923+
924+ it.effect("addServices starts each service Discovery fetch sequentially", () =>
925+ Effect.scoped(
926+ Effect.gen(function* () {
927+ let activeServiceFetches = 0;
928+ let maxActiveServiceFetches = 0;
929+ const serviceFetchOrder: string[] = [];
930+ const sequentialLayer = makeDiscoveryHttpClientLayer({
931+ beforeResponse: (url) => {
932+ if (url === OAUTH2_URL) return Effect.void;
933+ return Effect.gen(function* () {
934+ activeServiceFetches += 1;
935+ maxActiveServiceFetches = Math.max(maxActiveServiceFetches, activeServiceFetches);
936+ serviceFetchOrder.push(url);
937+ yield* waitOneTurn;
938+ activeServiceFetches -= 1;
939+ });
940+ },
941+ });
942+ const executor = yield* createExecutor(
943+ makeTestConfig({
944+ plugins: [
945+ googlePlugin({ httpClientLayer: sequentialLayer }),
946+ memoryCredentialsPlugin(),
947+ ],
948+ }),
949+ );
950+
951+ const result = yield* executor.google.addServices({
952+ services: [
953+ { presetId: "google-calendar" },
954+ { presetId: "google-gmail" },
955+ { presetId: "google-drive" },
956+ ],
957+ });
958+
959+ expect(result.failed).toEqual([]);
960+ expect(serviceFetchOrder).toEqual([CALENDAR_URL, GMAIL_URL, DRIVE_URL]);
961+ expect(maxActiveServiceFetches).toBe(1);
962+ }),
963+ ),
964+ );
965+});
966+
704967 describe("Google health-check default", () => {
705968 it.effect("default featured bundle auto-configures OAuth2 userinfo identity", () =>
706969 Effect.scoped(