Skip to content

test(e2e): cover a real adapter with a mocked Imgur API - #1520

Draft
alexeyqu wants to merge 6 commits into
feat/imgur-configurable-urlsfrom
test/e2e-imgur
Draft

alexeyqu wants to merge 6 commits into
feat/imgur-configurable-urlsfrom
test/e2e-imgur

Conversation

@alexeyqu

@alexeyqu alexeyqu commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Adds an Imgur → Imgur suite to the e2e harness, driving the real, unmodified ImgurPhotosExporter and ImgurPhotosImporter against a WireMock stand-in for api.imgur.com.

./e2e/run.sh                  # both adapters, ~50s warm
./e2e/run.sh imgur            # just one

Until now the harness only ran offline-demo → offline-demo: a fixed string exported by a ten-line exporter and printed by a System.out.println importer. That proves the machinery — job creation, auth, worker claim, copier, importer — and nothing about a data path. A green run now additionally covers a real OAuth2 token exchange, pagination on two independent axes, sub-resource recursion, an image byte round trip through LocalTempFileStore, and the original→new album id mapping IdempotentImportExecutor maintains across copy iterations.

Stack

PR Scope
#1509 (feat/offline-demo-exporter) offline-demo becomes a complete export/import pair
#1516 (test/e2e-offline-demo-compose) the compose + pytest harness
#1519 (feat/imgur-configurable-urls) Imgur URLs become configurable — base of this PR
this the suite that uses them

Retarget down the stack as the parents land.

One cold JVM per adapter

run.sh becomes a loop, and each adapter gets its own freshly started dtp container. This is not tidiness: LocalJobStore keeps jobs in private static maps and LocalTempFileStore keeps files on disk, and nothing clears either between jobs. A shared server would make isolation a matter of luck and ordering.

dtp therefore joins per-adapter compose profiles; gradle and e2e deliberately stay out of every profile so docker compose run --rm gradle keeps working verbatim, since docker-build.yml and Documentation/Developer.md both hardcode it.

The loop runs every adapter even when one fails, and captures per-adapter server logs and mock request journals to e2e/.logs/ before teardown. Cost is one extra JVM boot per adapter, measured at ~17s.

Each adapter also passes standalone, which is the check that the isolation is real.

How the mock URL reaches the adapter

Config resolution is classpath-only — there is no environment-variable override anywhere in the chain — and ConfigUtils/TransferServiceConfig both use the singular getResourceAsStream, so first match per filename wins. The dtp service now runs

java -cp /workspace/e2e/config:<jar> org.datatransferproject.bootstrap.vm.SingleVMMain

rather than java -jar, which ignores -cp entirely. e2e/config holds only config/imgur.yaml, a file the jar does not ship, so it is purely additive and inert for every other adapter. Shadowing a file the jar does ship (deezer.yaml, flickr.yaml, synology.yaml) would replace it wholesale rather than merge, silently dropping settings like perUserRateLimit — noted in the README.

Two assertions that were initially vacuous

Both were written, ran green, and covered nothing. They are recorded in e2e/README.md because they are the same failure mode as the 0 error(s) trap #1516 documented: an assertion that is true on a green run, but would also be true if the thing it claims to check never happened.

The byte-exactness check had a circular oracle. It compares each uploaded image against the fixture files — which are the files WireMock serves. Corrupting a fixture moves the subject and the oracle together, so the negative check passed when it should have failed. The assertion is still worth having: it proves each distinct fixture arrived exactly once, unmodified, which catches temp-store cross-talk, truncation and duplicate imports. What was wrong was the negative check, which has to break the pipeline — make the mock serve the wrong file — not the fixture. It then fails correctly with album2Photo1 was uploaded more than once.

The pagination check asserted the wrong page. It checked that page 1 was requested. But ImgurPhotosExporter derives "there is more" from items.size() != 0, so it always requests page N+1 after any non-empty page — meaning page 1 is fetched even when page 0 was the last page with data. That would pass on single-page fixtures, covering none of the pagination Imgur was chosen for. It now asserts page 2, the first request that can only happen if page 1 had content.

Notes for reviewers

  • Fixture seeding is load-bearing. Three albums across two pages, plus loose photos across two more, plus an empty-page terminator on each axis — Imgur's API carries no last-page flag, so without the terminator the export never stops asking. A green run performs 9 copy iterations.
  • The account-wide listing intentionally repeats album photos. The exporter is supposed to filter them out; if it stops doing so they get imported twice and the per-image assertion catches it. A passing run fetches exactly 6 images, not 10.
  • A clean run now emits SEVERE lines. On a same-service transfer WorkerModule resolves one extension instance and calls initialize() on it repeatedly, and ImgurTransferExtension logs each repeat at SEVERE — four lines on a successful run. The fail-fast heuristic requires the job id on the line and these carry none, so it does not trip, but test(e2e): add black-box offline-demo transfer harness #1516's "a clean run emits zero SEVERE lines" assumption is now only true per-job. Downgrading that log line is a candidate one-word fix:, deliberately not bundled here.
  • WireMock is mounted read-only. It runs as root and will mkdir any missing mappings//__files/ in a bind mount, leaving root-owned directories in your checkout.
  • Schema validation of delivered payloads and the X-DTP-* header assertions are not here — those belong to the generic importer, not Imgur, and stay with the schema work.

Verification

No workflow runs on a stacked PR (pull_request: branches: [master] matches the base), so this was verified by hand:

./e2e/run.sh                   # both adapters green
./e2e/run.sh imgur             # passes standalone, cold JVM
./e2e/run.sh offline-demo      # #1516's run, unchanged
docker compose run --rm gradle --no-daemon check    # BUILD SUCCESSFUL

Four negative checks, none committed:

  1. Mock serves album 2's bytes for album 3's photo → album2Photo1 was uploaded more than once.
  2. Albums collapsed to a single page of data → album pagination never got past the first page of data.
  3. baseUrl pointed at a dead port → fails in seconds on the SEVERE line with the server's own CopyException.
  4. Empty-page terminator stub deleted → also a fast CopyException, not the infinite loop expected: WireMock's 404 body is not parseable JSON, so requestData throws before the loop can run away.

alexeyqu and others added 6 commits August 18, 2026 00:57
OfflineDemoTransferExtension.getExporter() returned a literal null, so
the one extension in the repo that bypasses OAuth entirely was
import-only and could not drive a transfer. Implement it, so
offline-demo -> offline-demo becomes a complete, credential-free path.

Replace MicrosoftOfflineData with a local DemoOfflineData. That type is
a 20-line String wrapper, and depending on it pulled the entire
Microsoft adapter -- okhttp plus every Graph exporter and importer --
into a demo module. Exporter<A, T extends DataModel> bounds T on
DataModel rather than ContainerResource, so a plain value class is
type-legal here.

Also gate both getExporter and getImporter on OFFLINE_DATA. getImporter
previously returned the importer for any vertical; unreachable today,
since the auth extension advertises only OFFLINE_DATA, but
TransferCompatibilityProvider probes extensions with MEDIA, PHOTOS and
VIDEOS and would take the answer.

Adds the module's first tests.

BREAKING CHANGE: OfflineDemoImporter's type parameter changes from
MicrosoftOfflineData to DemoOfflineData. This module is published to
Maven Central, so it is a binary-compatibility break for any external
consumer -- unlikely for a demo importer that prints to stdout, but
worth a release note.
Runs a complete offline-demo -> offline-demo transfer against the packaged
demo-server jar and asserts that the exported payload actually arrives.
One command, no provider credentials, no local JDK and no local Python:

    ./e2e/run.sh

Three services in the root compose file, two of which share the repo's
single Dockerfile. No new Dockerfile, no build.gradle, no settings.gradle
entry, no Java.

Also renames the existing `test` service to `gradle`. It was confusingly
named: it runs the Gradle build, while the service that actually runs tests
is a different one. Developer.md had the tell -- `docker compose run --rm
test test --tests SomeTest`, service name and Gradle task colliding on one
word. `gradle` is exact, since the service's ENTRYPOINT is ./gradlew. The
name `test` is deliberately left unused rather than reassigned to the pytest
service: it is documented and hardcoded in docker-build.yml, so a `test`
that survived but meant something else would silently run the e2e suite for
anyone with the old command in their fingers, and pass. It now fails with
"no such service". Developer.md and docker-build.yml are updated to match,
and a stale claim that the root Dockerfile pins gradle:8.10.2-jdk11 is
corrected -- it pins eclipse-temurin:11-jdk-jammy.

The jar is built through the `gradle` service, whose ENTRYPOINT is already
./gradlew, so arguments pass straight through. That keeps the pinned Gradle
6.9.2 / JDK 11 toolchain single-sourced and reuses the warm gradle-cache
volume, which a `docker build` stage could not mount. shadowJar has no path
to copyWebApp, so no Node or Angular toolchain is involved.

`dtp` overrides the image entrypoint to run `java -jar` on that shadowJar,
so the harness exercises the real packaged artifact including its
mergeServiceFiles() META-INF/services merge. It deliberately defines no
image of its own: the repo publishes no server image today, so the harness
pins a contract -- an API on 8080 plus a readable log stream -- that a
future image can satisfy by replacing three keys on one service.

Completion is read from the server log because there is no alternative.
TransferJob.state is a hardcoded CREATED with no getter, so Jackson never
emits it and GET /api/transfer/{id} reports no progress at all.

Note that "Finished processing ... with 0 error(s)" is necessary but not
sufficient: the count is copier.getErrors().size(), so a job that never
copied anything reports zero too. Failure is therefore detected from SEVERE
lines naming the job -- a clean run emits none -- and the delivered payload
is asserted separately from job completion. Both assertions were verified
by deliberately breaking them.

CI runs the harness non-blocking to begin with, uploading the server log as
an artifact whether the run passes or fails.
ImgurTransferExtension hardcoded the API root and ImgurOAuthConfig
hardcoded the authorization and token endpoints, so pointing the adapter
at a staging environment or a test double meant rebuilding it.

Both now read from config/imgur.yaml on the classpath, falling back to
Imgur's own endpoints when no file is present:

    serviceConfig:
      baseUrl:  https://api.imgur.com/3
      authUrl:  https://api.imgur.com/oauth2/authorize
      tokenUrl: https://api.imgur.com/oauth2/token

This follows the convention Flickr, Deezer and Synology already use for
other per-service settings; Imgur had simply not adopted it. Both the
exporter and the importer already took baseUrl as a constructor
parameter, so only the extension-level constants needed to change.

One asymmetry worth noting for review: ImgurOAuthConfig calls the static
TransferServiceConfig.getForService rather than taking an injected
instance, because OAuth2ServiceExtension is never handed a
service-scoped one the way WorkerModule hands one to a TransferExtension.

Behaviour is unchanged when no config/imgur.yaml is on the classpath,
which is the case for every distribution in this repo today.

Adds the first tests for either class.
The harness so far only ran offline-demo -> offline-demo: a fixed string
exported by a ten-line exporter and printed by a System.out.println
importer. That proves the machinery -- job creation, auth, worker claim,
copier, importer -- and nothing about a data path.

Adds an Imgur -> Imgur suite driving the real, unmodified
ImgurPhotosExporter and ImgurPhotosImporter against a WireMock stand-in
for api.imgur.com. A green run now covers a real OAuth2 token exchange,
pagination on two independent axes, sub-resource recursion, an image
byte round trip through LocalTempFileStore, and the album-id mapping
IdempotentImportExecutor maintains across copy iterations.

Each adapter gets its own freshly started dtp container. LocalJobStore
keeps jobs in private static maps and LocalTempFileStore keeps files on
disk, and nothing clears either between jobs, so a shared server would
make isolation a matter of luck and ordering. run.sh loops over adapters,
runs all of them even when one fails, and captures per-adapter logs and
mock request journals. A cold JVM per adapter costs about 17 seconds.

The mock URLs reach the adapter through a classpath-prepended directory:
config resolution is classpath-only and first-match-per-filename, so dtp
now runs `java -cp /workspace/e2e/config:<jar> SingleVMMain` rather than
`java -jar`, which ignores -cp. e2e/config holds only files the jar does
not ship, so it is inert for every other adapter.

Two assertions in this suite were initially vacuous and are recorded in
e2e/README.md so the next person does not repeat them:

  - Comparing delivered bytes against the fixture the mock serves makes
    the fixture its own oracle; corrupting it corrupts both sides. The
    assertion still proves each distinct fixture arrived exactly once,
    unmodified, but its negative check has to break the pipeline rather
    than the fixture.
  - Asserting that page 1 was requested proves nothing, because the
    exporter requests page N+1 whenever page N is non-empty. It now
    asserts page 2, the first request that proves page 1 had content.

Verified by hand, since no workflow runs on a stacked PR: both adapters
green together and standalone, `gradle check` green, and four negative
checks confirming the assertions bite.
@alexeyqu
alexeyqu added this pull request to stack #1517 September 14, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant