# `Ltix.Test`
[🔗](https://github.com/DecoyLex/ltix/blob/main/lib/ltix/test.ex#L1)

Helpers for testing applications that use Ltix.

Reduces LTI test setup to a single call. `setup_platform!/1` generates
RSA keys, builds JWKS payloads, and creates registrations and
deployments.

    setup do
      %{platform: Ltix.Test.setup_platform!()}
    end

Your application's own storage adapter (configured in `config/test.exs`)
handles persistence. `setup_platform!/1` provides the platform-side
simulation, which is RSA keys, a JWKS endpoint stub, and
registration/deployment data your adapter can look up. Without an adapter of your own, start the
in-memory `Ltix.Test.StorageAdapter` with the platform.

Add the optional `:plug` dependency to your test environment to use
`setup_platform!/1` and HTTP stubs. The stubs take effect only when Ltix
sends its requests through `Req.Test`, so add this to `config/test.exs`.

    config :ltix, req_options: [plug: {Req.Test, :ltix}]

## Connecting to your storage adapter

Pass a `:registration` function to create records in your own persistence
layer. The function receives a valid `Ltix.Registration` with the
platform details and returns your app's struct (which must implement
`Ltix.Registerable`).

    setup do
      jwk = MyApp.Lti.generate_jwk!()

      platform = Ltix.Test.setup_platform!(
        registration: fn reg ->
          MyApp.Lti.create_registration!(%{
            issuer: reg.issuer,
            client_id: reg.client_id,
            auth_endpoint: reg.auth_endpoint,
            jwks_uri: reg.jwks_uri,
            token_endpoint: reg.token_endpoint,
            jwk_id: jwk.id
          })
        end,
        tool_jwk: Ltix.JWK.new!(private_key_pem: jwk.private_key_pem, kid: jwk.kid),
        deployment: fn dep, my_reg ->
          MyApp.Lti.create_deployment!(%{
            deployment_id: dep.deployment_id,
            registration_id: my_reg.id
          })
        end
      )

      %{platform: platform}
    end

## Controller tests (full OIDC flow)

Simulate a platform-initiated launch against your controller endpoints.
Your app's storage adapter resolves registrations and nonces as it would
in production.

    test "instructor launch renders dashboard", %{conn: conn, platform: platform} do
      conn = post(conn, ~p"/lti/login", Ltix.Test.login_params(platform))

      state = get_session(conn, :lti_state)
      nonce = Ltix.Test.extract_nonce(redirected_to(conn, 302))

      conn =
        conn
        |> recycle()
        |> Plug.Test.init_test_session(%{lti_state: state})
        |> post(~p"/lti/launch",
          Ltix.Test.launch_params(platform,
            nonce: nonce,
            state: state,
            roles: [:instructor],
            name: "Jane Doe"
          )
        )

      assert html_response(conn, 200) =~ "Dashboard"
    end

## Unit tests (direct context construction)

When testing business logic that receives a `%LaunchContext{}`, skip
the OIDC flow entirely with `build_launch_context/2`.

    test "instructors can manage grades", %{platform: platform} do
      context = Ltix.Test.build_launch_context(platform,
        roles: [:instructor],
        name: "Jane Smith"
      )

      assert MyApp.Permissions.can_manage_grades?(context)
    end

## Hand-built tokens

To send a token `launch_params/2` doesn't build, start from
`valid_lti_claims/2`, change the claims, and sign them with the
platform's key using `sign_id_token/3`.

    claims = Map.delete(Ltix.Test.valid_lti_claims(platform, %{"nonce" => nonce}), "sub")
    params = %{"id_token" => Ltix.Test.sign_id_token(claims, platform.jwk), "state" => state}

## Advantage service tests

When testing code that calls `Ltix.GradeService` or
`Ltix.MembershipsService`, stub the OAuth token endpoint and each
operation your code calls. Each stub handles one operation, stubbing an
operation again replaces its stub, and a request without a matching stub
fails the test with its method and URL.

    setup do
      platform = Ltix.Test.setup_platform!()

      Ltix.Test.stub_token_response(scopes: [
        "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
        "https://purl.imsglobal.org/spec/lti-ags/scope/score"
      ])

      Ltix.Test.stub_list_line_items([
        %Ltix.GradeService.LineItem{
          id: "https://platform.example.com/lineitems/1",
          label: "Quiz",
          score_maximum: 100
        }
      ])

      Ltix.Test.stub_post_score()

      %{platform: platform}
    end

    test "posts a score to every line item", %{platform: platform} do
      context = Ltix.Test.build_launch_context(platform,
        ags_endpoint: %{
          line_items: "https://platform.example.com/lineitems",
          scopes: [
            "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
            "https://purl.imsglobal.org/spec/lti-ags/scope/score"
          ]
        }
      )

      {:ok, client} = Ltix.GradeService.authenticate(context)

      score =
        Ltix.GradeService.Score.new!(
          user_id: "student-42",
          score_given: 85,
          score_maximum: 100,
          activity_progress: :completed,
          grading_progress: :fully_graded
        )

      for item <- Ltix.GradeService.list_line_items!(client) do
        :ok = Ltix.GradeService.post_score(client, score, line_item: item)
      end
    end

See the [Testing LTI Launches](testing-lti-launches.md) cookbook for
more examples, including role customization, raw claim overrides, and
memberships service testing.

# `assert_storage_adapter!`

```elixir
@spec assert_storage_adapter!(module(), keyword()) :: :ok
```

Assert that a storage adapter satisfies `Ltix.StorageAdapter`.

Checks registration and deployment lookups, returned records, and nonce
replay prevention. For an adapter that exports `key_id/2` and `sign/4`,
also checks that its signatures verify against your published key set
and that it returns an error for a key ID it can't sign with. Returns
`:ok` or raises `ExUnit.AssertionError` with the failing contract check.

Two parts of the contract can't be checked from outside your storage,
that nonce records are kept until `expires_at` and that storage failures
return `{:error, reason}`.

The deployment, nonce, and signing callbacks receive the registration
record your adapter's `get_registration/3` returns, as they do in a launch.

Use isolated test storage and your application's normal test cleanup.

## Options

  * `:registration` is a required 1-arity factory. Receives a valid
    `Ltix.Registration` and returns your stored record implementing
    `Ltix.Registerable`. Preserve the supplied issuer and client ID.
  * `:deployment` is a required 2-arity factory. Receives an
    `Ltix.Deployment` and your registration record. Returns the stored
    record implementing `Ltix.Deployable`. Preserve the supplied deployment
    ID. Lookups must return the same stored record.
  * `:registration_identity` and `:deployment_identity` are optional 1-arity
    functions that return a record's storage identity, such as `& &1.id`.
    By default, the complete factory and lookup records must match. Use
    these functions when lookups preload associations or change metadata.
    Protocol-converted fields must still match.
  * `:jwks` is a 1-arity function that receives a registration record and
    returns the key set your tool publishes for it, such as the result of
    `Ltix.JWK.to_jwks/1`. Required when the adapter exports `key_id/2` and
    `sign/4`, and rejected when it exports neither. The key set must
    include the key `key_id/2` returns, without private keys.
  * `:storage_adapter_options` is the keyword list each callback receives
    as its last argument. Defaults to `[]`.
  * `:prepare_process` is a 0-arity function called in each concurrent
    callback process. Use it to allow database sandbox access or set
    process-local adapter state. Defaults to a no-op.
  * `:auto_create_deployments` defaults to `false`, requiring missing
    deployments to return `{:error, :not_found}`. Set to `true` for adapters
    that create a deployment when none exists. Return distinct storage
    identities to check that registrations with the same deployment ID
    get separate automatically created deployments.
  * `:timeout` is the positive timeout in milliseconds for concurrent
    process preparation and validation. Defaults to `5000`.

    test "storage adapter satisfies the contract" do
      parent = self()

      :ok = Ltix.Test.assert_storage_adapter!(MyApp.LtiStorage,
        registration: fn registration ->
          MyApp.Lti.create_registration!(Map.from_struct(registration))
        end,
        deployment: fn deployment, registration ->
          MyApp.Lti.create_deployment!(%{
            deployment_id: deployment.deployment_id,
            registration_id: registration.id
          })
        end,
        prepare_process: fn ->
          Ecto.Adapters.SQL.Sandbox.allow(MyApp.Repo, parent, self())
        end
      )
    end

# `build_launch_context`

```elixir
@spec build_launch_context(Ltix.Test.Platform.t(), keyword()) ::
  Ltix.LaunchContext.t()
```

Build a `%LaunchContext{}` directly for unit testing.

Returns the context `Ltix.handle_callback/3` would return for a launch
from `launch_params/2` with the same launch options, without the OIDC flow.

    context = Ltix.Test.build_launch_context(platform,
      roles: [:instructor, :teaching_assistant],
      name: "Jane Smith",
      context: %{id: "course-1", title: "Elixir 101"}
    )

For Deep Linking contexts, pass `message_type: :deep_linking`.

    context = Ltix.Test.build_launch_context(platform,
      message_type: :deep_linking,
      deep_linking_settings: %{accept_types: ["ltiResourceLink"]}
    )

## Options

* `:message_type` (`:resource_link` | `:deep_linking`) - `:deep_linking` for a Deep Linking request. The default value is `:resource_link`.

* `:roles` (list of `t:atom/0` | `t:String.t/0` | struct of type `Ltix.LaunchClaims.Role`) - Role atoms such as `:instructor`, `%Ltix.LaunchClaims.Role{}` structs, or role URIs. URIs Ltix does not recognize are listed in `unrecognized_roles`. The default value is `[:instructor]`.

* `:subject` (`nil` | `t:String.t/0`) - User identifier. Defaults to `"user-12345"`. Pass `nil` for an anonymous launch.

* `:name` (`t:String.t/0`) - Full name.

* `:email` (`t:String.t/0`) - Email address.

* `:given_name` (`t:String.t/0`) - Given name.

* `:family_name` (`t:String.t/0`) - Family name.

* `:target_link_uri` (`t:String.t/0`) - Launch URL. The default value is `"https://tool.example.com/launch"`.

* `:context` (struct of type `Ltix.LaunchClaims.Context` | `t:map/0`) - Course or section, as a `%Ltix.LaunchClaims.Context{}` or a map of its fields. The `:id` defaults to `"context-001"`.

* `:resource_link` (struct of type `Ltix.LaunchClaims.ResourceLink` | `t:map/0`) - Resource link of a resource link launch, as a `%Ltix.LaunchClaims.ResourceLink{}` or a map of its fields. Defaults to `%{id: "resource-link-001", title: "Example Assignment"}`.

* `:deep_linking_settings` (struct of type `Ltix.LaunchClaims.DeepLinkingSettings` | `t:map/0`) - Settings of a Deep Linking request, as a `%Ltix.LaunchClaims.DeepLinkingSettings{}` or a map of its fields. Fields you leave out accept the five standard content types, every presentation target, and multiple items.

* `:ags_endpoint` (struct of type `Ltix.LaunchClaims.AGSEndpoint` | `t:map/0`) - Grade service endpoint, as a `%Ltix.LaunchClaims.AGSEndpoint{}` or a map of its fields. Requires `:scopes`.

* `:memberships_endpoint` (struct of type `Ltix.LaunchClaims.MembershipsEndpoint` | `t:String.t/0` | `t:map/0`) - Memberships service endpoint, as a `%Ltix.LaunchClaims.MembershipsEndpoint{}`, its URL, or a map of its fields. `:service_versions` defaults to `["2.0"]`.

* `:claims` (`t:map/0`) - Raw claims merged over the built claims, keyed by claim name.

# `extract_nonce`

```elixir
@spec extract_nonce(String.t()) :: String.t()
```

Extract the nonce from a login redirect URI.

    {:ok, result} = Ltix.handle_login(params, redirect_uri)
    nonce = Ltix.Test.extract_nonce(result.redirect_uri)

# `launch_params`

```elixir
@spec launch_params(Ltix.Test.Platform.t(), keyword()) :: map()
```

Build POST params for `Ltix.handle_callback/3`.

Signs a JWT with the platform's private key and returns
`%{"id_token" => jwt, "state" => state}`. Takes the same launch options
as `build_launch_context/2`, plus the `:nonce` and `:state` of the login.

## Options

* `:nonce` (`t:String.t/0`) - Required. Nonce from the login redirect, read with `extract_nonce/1`.

* `:state` (`t:String.t/0`) - Required. State from the login result.

* `:message_type` (`:resource_link` | `:deep_linking`) - `:deep_linking` for a Deep Linking request. The default value is `:resource_link`.

* `:roles` (list of `t:atom/0` | `t:String.t/0` | struct of type `Ltix.LaunchClaims.Role`) - Role atoms such as `:instructor`, `%Ltix.LaunchClaims.Role{}` structs, or role URIs. URIs Ltix does not recognize are listed in `unrecognized_roles`. The default value is `[:instructor]`.

* `:subject` (`nil` | `t:String.t/0`) - User identifier. Defaults to `"user-12345"`. Pass `nil` for an anonymous launch.

* `:name` (`t:String.t/0`) - Full name.

* `:email` (`t:String.t/0`) - Email address.

* `:given_name` (`t:String.t/0`) - Given name.

* `:family_name` (`t:String.t/0`) - Family name.

* `:target_link_uri` (`t:String.t/0`) - Launch URL. The default value is `"https://tool.example.com/launch"`.

* `:context` (struct of type `Ltix.LaunchClaims.Context` | `t:map/0`) - Course or section, as a `%Ltix.LaunchClaims.Context{}` or a map of its fields. The `:id` defaults to `"context-001"`.

* `:resource_link` (struct of type `Ltix.LaunchClaims.ResourceLink` | `t:map/0`) - Resource link of a resource link launch, as a `%Ltix.LaunchClaims.ResourceLink{}` or a map of its fields. Defaults to `%{id: "resource-link-001", title: "Example Assignment"}`.

* `:deep_linking_settings` (struct of type `Ltix.LaunchClaims.DeepLinkingSettings` | `t:map/0`) - Settings of a Deep Linking request, as a `%Ltix.LaunchClaims.DeepLinkingSettings{}` or a map of its fields. Fields you leave out accept the five standard content types, every presentation target, and multiple items.

* `:ags_endpoint` (struct of type `Ltix.LaunchClaims.AGSEndpoint` | `t:map/0`) - Grade service endpoint, as a `%Ltix.LaunchClaims.AGSEndpoint{}` or a map of its fields. Requires `:scopes`.

* `:memberships_endpoint` (struct of type `Ltix.LaunchClaims.MembershipsEndpoint` | `t:String.t/0` | `t:map/0`) - Memberships service endpoint, as a `%Ltix.LaunchClaims.MembershipsEndpoint{}`, its URL, or a map of its fields. `:service_versions` defaults to `["2.0"]`.

* `:claims` (`t:map/0`) - Raw claims merged over the built claims, keyed by claim name.

# `login_params`

```elixir
@spec login_params(Ltix.Test.Platform.t(), keyword()) :: map()
```

Build POST params for `Ltix.handle_login/3`.

## Options

  * `:login_hint` - login hint value (defaults to `"user-hint"`)
  * `:target_link_uri` - launch URL (defaults to `"https://tool.example.com/launch"`)

# `setup_platform!`

```elixir
@spec setup_platform!(keyword()) :: Ltix.Test.Platform.t()
```

Set up a simulated LTI platform in one call.

Generates platform-side RSA keys, builds a registration and deployment,
and stubs the JWKS HTTP endpoint. The configured `Ltix.JWT.KeySet` must be
started. Your app's own storage adapter (configured via
`config :ltix, storage_adapter: ...`) handles persistence during the OIDC
flow.

## Options

  * `:issuer` - platform issuer URL (defaults to `"https://platform.example.com"`)
  * `:client_id` - OAuth client ID (defaults to `"tool-client-id"`)
  * `:deployment_id` - deployment identifier (defaults to `"deployment-001"`)
  * `:registration` - either an app struct implementing `Ltix.Registerable`
    (mutually exclusive with `:issuer` and `:client_id`), or a 1-arity
    function that receives an `Ltix.Registration` and returns your app's
    struct. The function form lets you create a matching record in your
    own persistence layer using the platform details (issuer, client_id,
    endpoints). Works with `:issuer` and `:client_id` overrides.
  * `:deployment` - either an app struct implementing `Ltix.Deployable`
    (mutually exclusive with `:deployment_id`), or a 2-arity function
    `(Ltix.Deployment, registration)` where `registration` is whatever
    the registration step returned. Lets you create a deployment record
    linked to your registration.
  * `:tool_jwk` - the `Ltix.JWK` your tool signs with for this platform.
    Pass the key your own storage adapter signs with. Defaults to the
    key `Ltix.Test.StorageAdapter` signs with.

# `sign_id_token`

```elixir
@spec sign_id_token(map(), Ltix.JWK.t(), keyword()) :: String.t()
```

Sign claims as an ID token with a platform key.

Use it to build launch tokens by hand, such as a token missing a claim.

    claims = Map.delete(Ltix.Test.valid_lti_claims(platform), "sub")
    id_token = Ltix.Test.sign_id_token(claims, platform.jwk)

## Options

* `:kid` (`nil` | `t:String.t/0`) - Key ID for the token header. Defaults to the key's `kid`. Pass `nil` to leave it out.

* `:alg` (`t:String.t/0`) - Signing algorithm. The default value is `"RS256"`.

# `stub_create_line_item`

```elixir
@spec stub_create_line_item(Ltix.GradeService.LineItem.t()) :: :ok
```

Stub `create_line_item/2` to return the given line item.

Pass the line item you want the platform to "return" (typically the
same fields the caller sent, plus an `:id` assigned by the platform).

    Ltix.Test.stub_create_line_item(
      %LineItem{id: "https://lms.example.com/lineitems/new", label: "Quiz 1", score_maximum: 100}
    )

# `stub_delete_line_item`

```elixir
@spec stub_delete_line_item(keyword()) :: :ok
```

Stub `delete_line_item/2` to succeed.

    Ltix.Test.stub_delete_line_item()

## Options

* `:line_item` (`t:String.t/0` | struct of type `Ltix.GradeService.LineItem`) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.

# `stub_get_line_item`

```elixir
@spec stub_get_line_item(Ltix.GradeService.LineItem.t(), keyword()) :: :ok
```

Stub `get_line_item/2` to return the given line item.

    Ltix.Test.stub_get_line_item(
      %LineItem{id: "https://lms.example.com/lineitems/1", label: "Quiz 1", score_maximum: 100}
    )

## Options

* `:line_item` (`t:String.t/0` | struct of type `Ltix.GradeService.LineItem`) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.

# `stub_list_line_items`

```elixir
@spec stub_list_line_items([Ltix.GradeService.LineItem.t()]) :: :ok
```

Stub `list_line_items/2` and `stream_line_items/2` to return the given
line items.

    Ltix.Test.stub_list_line_items([
      %LineItem{id: "https://lms.example.com/lineitems/1", label: "Quiz 1", score_maximum: 100},
      %LineItem{id: "https://lms.example.com/lineitems/2", label: "Quiz 2", score_maximum: 50}
    ])

Responds to a request with a `:per_page` size one page at a time, linking
to the next page like a platform does.

# `stub_list_members`

```elixir
@spec stub_list_members([Ltix.MembershipsService.Member.t()], keyword()) :: :ok
```

Stub `list_members/2` and `stream_members/2` to return the given members.

    Ltix.Test.stub_list_members([
      %Member{user_id: "student-1", roles: [Role.from_atom(:learner)], name: "Alice"},
      %Member{user_id: "student-2", roles: [Role.from_atom(:instructor)], name: "Bob"}
    ])

Responds to a request with a `:per_page` size one page at a time, linking
to the next page like a platform does.

## Options

* `:context` (struct of type `Ltix.LaunchClaims.Context` | `t:map/0`) - Course or section, as a `%Ltix.LaunchClaims.Context{}` or a map of its fields. The `:id` defaults to `"context-001"`.

* `:id` (`t:String.t/0`) - Container ID URL.

# `stub_list_results`

```elixir
@spec stub_list_results([Ltix.GradeService.Result.t()], keyword()) :: :ok
```

Stub `list_results/2` and `stream_results/2` to return the given results.

    Ltix.Test.stub_list_results([
      %Result{user_id: "student-1", result_score: 0.85, result_maximum: 1},
      %Result{user_id: "student-2", result_score: 0.92, result_maximum: 1}
    ])

Responds to a request with a `:per_page` size one page at a time, linking
to the next page like a platform does.

## Options

* `:line_item` (`t:String.t/0` | struct of type `Ltix.GradeService.LineItem`) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.

# `stub_post_score`

```elixir
@spec stub_post_score(keyword()) :: :ok
```

Stub `post_score/3` to succeed.

    Ltix.Test.stub_post_score()

## Options

* `:line_item` (`t:String.t/0` | struct of type `Ltix.GradeService.LineItem`) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.

# `stub_token_response`

```elixir
@spec stub_token_response(keyword()) :: :ok
```

Stub the OAuth token endpoint for advantage service tests.

Stubs the OAuth token endpoint so advantage service tests don't make
real HTTP calls. Call this in your test setup before authenticating a
service client.

    Ltix.Test.stub_token_response(scopes: [
      "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
      "https://purl.imsglobal.org/spec/lti-ags/scope/score"
    ])

## Options

  * `:scopes` - list of granted scope URIs (defaults to `[]`). An empty list
    grants the requested scopes.
  * `:access_token` - token string (defaults to `"test-token"`)
  * `:expires_in` - token lifetime in seconds (defaults to `3600`)

# `stub_update_line_item`

```elixir
@spec stub_update_line_item(Ltix.GradeService.LineItem.t(), keyword()) :: :ok
```

Stub `update_line_item/2` to return the given line item.

    Ltix.Test.stub_update_line_item(
      %LineItem{id: "https://lms.example.com/lineitems/1", label: "Updated Quiz", score_maximum: 100}
    )

## Options

* `:line_item` (`t:String.t/0` | struct of type `Ltix.GradeService.LineItem`) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.

# `valid_lti_claims`

```elixir
@spec valid_lti_claims(Ltix.Test.Platform.t(), map()) :: map()
```

Return a complete, valid LTI claim set.

The issuer, audience, and deployment ID are the platform's. Caller
can override individual claims via the `overrides` map.

    claims = Ltix.Test.valid_lti_claims(platform, %{"nonce" => nonce})
    id_token = Ltix.Test.sign_id_token(claims, platform.jwk)

# `verify_deep_linking_response`

```elixir
@spec verify_deep_linking_response(Ltix.Test.Platform.t(), String.t()) ::
  {:ok, map()} | {:error, Exception.t()}
```

Verify a Deep Linking response JWT signed by the tool.

Decodes the JWT using the public key of the platform's `tool_jwk` and
returns the parsed claims on success.
Returns `Ltix.Errors.Security.SignatureInvalid` when the token is
malformed or was not signed with the tool's key.

    {:ok, response} = Ltix.DeepLinking.build_response(context, items)
    {:ok, claims} = Ltix.Test.verify_deep_linking_response(platform, response.jwt)
    assert claims["https://purl.imsglobal.org/spec/lti/claim/message_type"] ==
             "LtiDeepLinkingResponse"

---

*Consult [api-reference.md](api-reference.md) for complete listing*
