# Test your integration

A SocialScope integration is mostly about refusing the right things: another user's return, another app's connection, a missing metric presented as zero, or success inferred from a redirect. This page explains where you can test, which cases to cover before launch, how to stub SocialScope in your own test suite, and what a passing test does and does not prove.

## Where to test

| Where                                    | What it proves                                                      |
| ---------------------------------------- | ------------------------------------------------------------------- |
| Your own test suite, SocialScope stubbed | Your routes, ownership checks, result handling and display rules    |
| The dev deployment                       | The real handoff, origins, return URLs and a live provider consent  |
| SocialScope's local fixture              | The whole Connect journey with synthetic accounts, no live provider |

### The dev deployment

- GraphQL: `https://connect.socialscope.hardscope.incdev.dev/graphql`
- Connect host: `https://connect.socialscope.hardscope.incdev.dev`

Your test environment needs its own client registration and key. Origins on the dev deployment must be exact HTTPS origins on a public DNS name, with no port. `http://localhost:3000` is not accepted, so test the browser flow from a deployed HTTPS environment of your app, registered as its own origin and return URL.

Disconnects behave differently on dev. On dev a disconnect affects only the one connection. In production, a YouTube or TikTok disconnect also suspends other accounts on that provider, cancels Connect attempts in progress and briefly makes the provider unavailable to new attempts, until its revoke settles. Dev testing cannot show those effects, so cover them with stubbed tests. See [Connect](/connect.md#disconnect).

Provider apps on dev can run in sandbox or testing mode, where only accounts added as testers can connect. Ask the SocialScope admin which test accounts to use for each provider. Readiness reports a provider as available only after the admin has enabled it for your app.

### SocialScope's local fixture

SocialScope can run locally with a fake provider that returns synthetic profiles, posts and metrics. It runs the real hosted handoff on `consumer.localhost` and `socialscope.localhost` in Chromium. It needs access to the SocialScope source, so ask the SocialScope team if you want to use it. The fixture proves wiring. It does not prove provider approval or a live consent.

## Cases to cover before launch

### Connect

- [ ] Two different users in the same tenant each connect an account. Neither can list or read the other's connection.
- [ ] A second app key, registered as a separate client, cannot read the first app's connections. The read returns `NOT_FOUND`.
- [ ] The return route rejects a missing, malformed, duplicate, foreign or already used `ss_session_id` without showing success.
- [ ] A return for user A opened while signed in as user B is rejected.
- [ ] Opening the return URL twice with the same hint does not show success twice.
- [ ] With a provider unavailable in readiness, its button shows as unavailable. A failed readiness call also disables it.
- [ ] Declining at the provider (`DENIED`) and letting an attempt expire (`EXPIRED`) both lead to a retry, not success.
- [ ] `COMPLETED` with a connection that is not `CONNECTED` does not show as connected.
- [ ] Each `syncStatus` renders correctly: `PENDING` and `RUNNING` as loading, `READY` as loaded, `PARTIAL` as possibly incomplete, `STALE` with its age, `FAILED` as an error with a retry.
- [ ] A stored connection whose read fails with `AUTHORIZATION_SUSPENDED`, `RECONNECT_REQUIRED`, `CAPABILITY_UNAVAILABLE` or `FORBIDDEN` shows that per-account state, and the other accounts still render.
- [ ] Any `creatorAction` shows the "remove the app at the provider" instruction.
- [ ] `ss_session_id` is gone from the URL before analytics or third-party scripts load.

### Data

- [ ] A metric with `availability` other than `AVAILABLE` renders as "not available", never `0`.
- [ ] `observedAt` is shown next to counts.
- [ ] A window with `completeForWindow: false` is not presented as the full list.
- [ ] `SYNC_WINDOW_BUSY` and `RATE_LIMITED` lead to a later retry, not an error page loop.

### Disconnect

- [ ] Disconnecting removes the account from your UI immediately.
- [ ] An Instagram disconnect always shows the "remove the app in Instagram settings" instruction.
- [ ] Your UI never claims a provider revoke unless the receipt is `SUCCEEDED` with a null `creatorAction`.

### Google identity, if used

- [ ] A new Google user creates one local user keyed by `issuer` and `googleSub`.
- [ ] A returning Google user signs in to the same local user, even if their email changed.
- [ ] A second redeem of the same result is rejected and signs nobody in.
- [ ] `DENIED`, `FAILED` and `EXPIRED` create no user.
- [ ] A hint from another browser session is rejected.

### Secrets

- [ ] The key is absent from every browser bundle. Search the built client assets for the key ID.
- [ ] Logs contain no `Authorization` header, launch token or result proof.

## Stub SocialScope in your own tests

Test your routes through their public interface and stub SocialScope at the HTTP boundary, the `fetch` to `SOCIALSCOPE_GRAPHQL_URL`. Answer with responses shaped like the [schema](/schema.graphql), including error responses with `extensions.code`.

This Vitest example checks that a return route refuses a hint that does not belong to the signed-in user, without calling SocialScope at all. `handleConnectReturn` and `seedAttempt` stand in for your own route and test fixture.

```ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { handleConnectReturn } from "../src/social/return-route";
import { seedAttempt } from "./fixtures";

const GRAPHQL_URL = "https://connect.socialscope.hardscope.incdev.dev/graphql";

function stubSocialScope(answer: (operation: string) => unknown) {
  const calls: string[] = [];
  vi.stubGlobal(
    "fetch",
    vi.fn(async (url: string, init: RequestInit) => {
      expect(url).toBe(GRAPHQL_URL);
      const { query } = JSON.parse(String(init.body)) as { query: string };
      const operation = /(?:query|mutation)\s+(\w+)/.exec(query)?.[1] ?? "anonymous";
      calls.push(operation);
      return new Response(JSON.stringify(answer(operation)), { headers: { "content-type": "application/json" } });
    }),
  );
  return calls;
}

afterEach(() => {
  vi.unstubAllGlobals();
});

describe("connect return route", () => {
  it("rejects another user's attempt without asking SocialScope", async () => {
    // Arrange
    process.env.SOCIALSCOPE_GRAPHQL_URL = GRAPHQL_URL;
    process.env.SOCIALSCOPE_CONSUMER_KEY = "test-key-id.test-secret";
    const attempt = await seedAttempt({ userId: "user-a", tenantKey: "example-workspace" });
    const calls = stubSocialScope(() => ({ data: null }));
    const url = new URL(`https://app.example.com/social/return?ss_session_id=${attempt.id}`);

    // Act
    const result = await handleConnectReturn(url, { userId: "user-b", tenantKey: "example-workspace" });

    // Assert
    expect(result.outcome).toBe("retry");
    expect(calls).toEqual([]);
  });

  it("does not show success when the connection is no longer connected", async () => {
    // Arrange
    process.env.SOCIALSCOPE_GRAPHQL_URL = GRAPHQL_URL;
    process.env.SOCIALSCOPE_CONSUMER_KEY = "test-key-id.test-secret";
    const attempt = await seedAttempt({ userId: "user-a", tenantKey: "example-workspace" });
    stubSocialScope((operation) =>
      operation === "VerifyConnection"
        ? {
            data: {
              connectSessionResult: {
                id: attempt.id,
                provider: "INSTAGRAM",
                status: "COMPLETED",
                resultCode: "CONNECTED",
                connectionId: "c0ffee00-0000-4000-8000-000000000001",
                creatorAction: null,
              },
            },
          }
        : {
            data: {
              connection: {
                id: "c0ffee00-0000-4000-8000-000000000001",
                provider: "INSTAGRAM",
                status: "RECONNECT_REQUIRED",
                syncStatus: "STALE",
              },
            },
          },
    );
    const url = new URL(`https://app.example.com/social/return?ss_session_id=${attempt.id}`);

    // Act
    const result = await handleConnectReturn(url, { userId: "user-a", tenantKey: "example-workspace" });

    // Assert
    expect(result.outcome).toBe("not-connected");
  });
});
```

Use real keys only in the dev environment's secret store, never in test files.

## Preview deployments

If your app deploys a preview per pull request on hosts that are only known at deploy time, SocialScope can serve one shared preview client per deployment. It matches a host template such as `https://pr-*-example-preview.example.com`, where `*` is the pull request number. Ask the SocialScope admin whether a preview client is configured for your app and which template and return paths it uses.

Rules for preview clients:

- The `*` is a pull request number of 1 to 6 digits with no leading zero. A port, `http`, a different suffix or a shared hosting domain never matches.
- Render the launch form on the concrete preview origin, the same origin as the return URL.
- Only Connect works. Google identity is never available to a preview client.
- Only provider apps in sandbox or testing mode serve preview clients, so only their test accounts can connect. Readiness can still report `true` for a provider that then refuses the attempt with `FORBIDDEN`.
- All previews share one key and one tenant. Prefix `externalSubjectId` with the pull request number, for example `pr-123:<userId>`, so previews that clone the same users never share connections. SocialScope cannot enforce this for you.

## What a passing test does not prove

- A stubbed suite proves your handling, not SocialScope's behavior or the provider's.
- The local fixture proves wiring, not provider approval.
- `integrationReadiness: true` proves configuration, not that a live consent will succeed.
- Before launch, connect one real account per provider on the target environment, read its posts, and disconnect it.
