Skip to content

Commit 0c63b67

Browse files
tijderclaude
andcommitted
feat: full-coverage e2e — all media types, mocked external sources, player reuse
- values-ci: all six library types (incl. COMIC, added to the schema) with testdata volumes; podcasts download into the cache PVC. - ci/mock-external.yaml: one WireMock pod serves TMDB, MusicBrainz, Cover Art Archive, Open Library, Wikidata/Wikipedia, Commons and iTunes under path prefixes; /img/* returns a real JPEG. Search stubs echo the query via response templating so the server's title guards match. - ci/e2e.sh is now an orchestrator (E2E_ONLY/E2E_SKIP) over ci/e2e/ scenarios: 10-scan, 15-metadata (enrichment lands, zero dead-letters), 20-podcast, 30-streaming (real transcode), 40-books, 50-search, 60-watch-status. - ci/up.sh + Makefile: local/CI bring-up with image pinning (SERVER_IMAGE_TAG etc.; migrations follows the server tag). - ci/podcast-feed.yaml serves the generated feed in-cluster. - ci/e2e/forward-for-player.sh: port-forwards for the player's integration tests, which reuse up.sh from their own workflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PVPFuUaADWUBoXg9ZjUe4P
1 parent f88792a commit 0c63b67

19 files changed

Lines changed: 1122 additions & 117 deletions

.github/workflows/ci.yml

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
e2e:
7171
name: End-to-end on kind
7272
runs-on: ubuntu-latest
73-
timeout-minutes: 25
73+
timeout-minutes: 35
7474
steps:
7575
- uses: actions/checkout@v4
7676
with:
@@ -93,33 +93,42 @@ jobs:
9393
id: fixtures
9494
uses: actions/cache@v4
9595
with:
96-
path: testdata/node1/disk1
97-
key: fixtures-${{ steps.testdata.outputs.sha }}-${{ hashFiles('testdata/create_mkv.sh') }}
96+
path: |
97+
testdata/node1/disk1
98+
testdata/podcast-feed
99+
key: fixtures-${{ steps.testdata.outputs.sha }}-${{ hashFiles('testdata/create_mkv.sh', 'testdata/create_books.sh', 'testdata/create_comics.sh', 'testdata/create_podcast_feed.sh') }}
98100

99101
# Only on a cache miss — which also means ffmpeg is only installed when it is used.
100102
- name: Generate media fixtures
101103
if: steps.fixtures.outputs.cache-hit != 'true'
102104
run: |
103-
sudo apt-get update && sudo apt-get install -y ffmpeg
105+
sudo apt-get update && sudo apt-get install -y ffmpeg zip
104106
cd testdata
105-
# create_mkv.sh walks every node*/disk* it finds and encodes a 3-minute video per
106-
# episode. The e2e only mounts node1/disk1/{tv,movies}, so drop the rest rather
107+
# The generators walk every node*/disk* they find and encode a 3-minute video per
108+
# episode. The e2e only mounts node1/disk1, so drop the other nodes/disks rather
107109
# than spend minutes encoding video nothing will ever read.
108-
rm -rf node2 node1/disk2 node1/disk1/music node1/disk1/books
109-
# create_mkv.sh's exit status is simply that of its last statement — a bare
110-
# `[ -d node1/disk1/music ] && create_music_structure …` test that we force false
111-
# by removing music above, so the script exits 1 even on a full success. It also
112-
# runs without `set -e`, so its exit code is no reliable success signal anyway.
113-
# Ignore it and gate on the fixtures that actually got produced instead.
110+
rm -rf node2 node1/disk2
111+
# create_mkv.sh runs without `set -e` and its exit status is simply that of its
112+
# last statement, so its exit code is no reliable success signal. Ignore it and
113+
# gate on the fixtures that actually got produced instead.
114114
./create_mkv.sh || true
115+
./create_books.sh
116+
./create_comics.sh
117+
# The feed is served in-cluster by ci/podcast-feed.yaml under this name.
118+
./create_podcast_feed.sh http://podcast-feed:8080
115119
count=$(find node1/disk1 -name '*.mkv' | wc -l)
116120
echo "generated $count mkv fixtures"
117121
[ "$count" -gt 0 ]
122+
[ -f podcast-feed/feed.xml ]
123+
[ "$(find node1/disk1/books -name '*.epub' | wc -l)" -gt 0 ]
124+
[ "$(find node1/disk1/comics -name '*.cbz' | wc -l)" -gt 0 ]
118125
119126
- name: Show the fixtures
120127
run: |
121128
echo "cache hit: ${{ steps.fixtures.outputs.cache-hit == 'true' }}"
122-
echo "mkv files: $(find testdata/node1/disk1 -name '*.mkv' | wc -l)"
129+
echo "mkv files: $(find testdata/node1/disk1 -name '*.mkv' | wc -l)"
130+
echo "epub files: $(find testdata/node1/disk1 -name '*.epub' | wc -l)"
131+
echo "cbz files: $(find testdata/node1/disk1 -name '*.cbz' | wc -l)"
123132
124133
- uses: azure/setup-helm@v4
125134
with:
@@ -142,11 +151,13 @@ jobs:
142151
config: chart/ci/kind-config.yaml
143152
cluster_name: ister
144153

145-
- name: Deploy the mock OIDC issuer
154+
- name: Deploy the mock OIDC issuer, podcast feed server and external-API mock
146155
run: |
147156
kubectl create namespace ister
148157
kubectl apply -n ister -f chart/ci/mock-oidc.yaml
149-
kubectl wait -n ister --for=condition=Available deploy/mock-oidc --timeout=120s
158+
kubectl apply -n ister -f chart/ci/podcast-feed.yaml
159+
kubectl apply -n ister -f chart/ci/mock-external.yaml
160+
kubectl wait -n ister --for=condition=Available deploy/mock-oidc deploy/podcast-feed deploy/mock-external --timeout=180s
150161
151162
- name: Install the chart
152163
working-directory: chart

Makefile

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Local test environment for the ister chart. See ci/up.sh for the details;
2+
# every target is idempotent.
3+
#
4+
# make up — fixtures + kind cluster + mock-oidc + podcast-feed + chart
5+
# make e2e — run the API end-to-end scenarios (ci/e2e.sh)
6+
# make player-e2e — run the player's Flutter integration tests against the cluster
7+
# make down — delete the kind cluster
8+
9+
CLUSTER_NAME ?= ister
10+
NAMESPACE ?= ister
11+
RELEASE ?= ister
12+
TESTDATA_DIR ?= $(abspath ../testdata)
13+
PLAYER_DIR ?= $(abspath ../player)
14+
15+
.PHONY: fixtures up e2e player-e2e down
16+
17+
fixtures:
18+
cd $(TESTDATA_DIR) && ./create_mkv.sh || true
19+
cd $(TESTDATA_DIR) && ./create_books.sh && ./create_comics.sh && ./create_podcast_feed.sh http://podcast-feed:8080
20+
21+
# Image pinning is passed through to ci/up.sh; default is the chart's own pinned version.
22+
# make up SERVER_IMAGE_TAG=1.2.0-SNAPSHOT
23+
# make up SERVER_IMAGE_REPOSITORY=localhost/ister-server SERVER_IMAGE_TAG=dev SERVER_IMAGE_PULL_POLICY=Never
24+
up:
25+
TESTDATA_DIR=$(TESTDATA_DIR) CLUSTER_NAME=$(CLUSTER_NAME) NAMESPACE=$(NAMESPACE) RELEASE=$(RELEASE) \
26+
SERVER_IMAGE_REPOSITORY=$(SERVER_IMAGE_REPOSITORY) SERVER_IMAGE_TAG=$(SERVER_IMAGE_TAG) \
27+
SERVER_IMAGE_PULL_POLICY=$(SERVER_IMAGE_PULL_POLICY) \
28+
MIGRATIONS_IMAGE_REPOSITORY=$(MIGRATIONS_IMAGE_REPOSITORY) MIGRATIONS_IMAGE_TAG=$(MIGRATIONS_IMAGE_TAG) \
29+
ci/up.sh
30+
31+
e2e:
32+
ci/e2e.sh $(RELEASE) $(NAMESPACE)
33+
34+
# Starts the forwards, runs the integration tests, and tears the forwards down.
35+
player-e2e:
36+
ci/e2e/forward-for-player.sh $(RELEASE) $(NAMESPACE) & \
37+
FWD_PID=$$!; \
38+
sleep 3; \
39+
(cd $(PLAYER_DIR) && flutter test integration_test -d linux --dart-define=ISTER_TEST_MODE=true); \
40+
STATUS=$$?; \
41+
kill $$FWD_PID 2>/dev/null; \
42+
exit $$STATUS
43+
44+
down:
45+
kind delete cluster --name $(CLUSTER_NAME)

README.md

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -225,42 +225,41 @@ moves regardless, so CI can go red without anyone touching this repo), on a
225225
publishing new images, and via `workflow_call` from the release workflow — a chart is never
226226
released on a red e2e.
227227

228-
Two levels of test:
228+
Three levels of test:
229229

230230
- **`helm test`** — unauthenticated, ships with the chart, so users can run it against
231231
their own install. Checks actuator health, `/.well-known/ister`, the website, Typesense,
232232
and the `getServerInfo` GraphQL query. That last one reads the node registry from the
233233
database, so it proves Postgres is up, Flyway migrated, and the server registered itself.
234-
- **`ci/e2e.sh`** — the real thing: mints a JWT, calls the `scanLibrary` mutation, and
235-
polls until the scanner has indexed shows and movies from the `ister-app/testdata`
236-
fixtures. This needs an OIDC issuer, because `scanLibrary` and every content query are
234+
- **`ci/e2e.sh`** — the real thing: mints a JWT and runs the scenario scripts in
235+
`ci/e2e/` in order: `10-scan` (every library type indexes: shows, movies, albums, books
236+
with audiobook chapters and media-overlay detection, comic series with page counts),
237+
`15-metadata` (enrichment through the mocked external sources actually lands, and zero
238+
events dead-letter), `20-podcast` (subscribe → refresh → download against the in-cluster
239+
`ci/podcast-feed.yaml` server), `30-streaming` (stream token → HLS master playlist → a
240+
real ffmpeg-transcoded segment), `40-books` (epub resources + reading-progress
241+
round-trip), `50-search` (Typesense, shows and movies) and `60-watch-status` (play queue
242+
heartbeat → recentlyWatched).
243+
This needs an OIDC issuer, because `scanLibrary` and every content query are
237244
`@PreAuthorize("hasRole('user')")` — hence `ci/mock-oidc.yaml`, a mock issuer minting
238-
tokens with a `roles: ["user"]` claim. Indexing itself needs no TMDB key and no internet:
239-
shows and movies are derived from filenames and local `.nfo` files.
240-
241-
To run it locally (needs kind, helm, jq, ffmpeg, and a container runtime):
245+
tokens with a `roles: ["user"]` claim. All external metadata sources (TMDB, MusicBrainz,
246+
Cover Art Archive, Open Library, Wikidata/Wikipedia, Wikimedia Commons, iTunes) are
247+
served by `ci/mock-external.yaml`, a WireMock pod the server is pointed at via
248+
`server.extraEnv` in `ci/values-ci.yaml` — deterministic, offline, no rate limits, no
249+
real TMDB key. `E2E_ONLY='30-*'`/`E2E_SKIP` select scenarios during local iteration.
250+
- **Player integration tests** — live in the `ister-app/player` repo
251+
(`integration_test/`) and run the real Flutter app against this same kind deployment:
252+
add-server flow, movie playback over HLS, audiobook and podcast playback, epub reading
253+
with progress sync, and read-aloud. Their CI job checks out this chart and reuses
254+
`ci/up.sh`. The tests reach the server on `localhost:8080` (the chart's default
255+
advertised `server.url`) via `ci/e2e/forward-for-player.sh`.
256+
257+
To run it locally (needs kind, helm, jq, ffmpeg, zip, and a container runtime; the
258+
testdata repo cloned next to this one):
242259

243260
```sh
244-
git clone https://github.com/ister-app/testdata ../testdata
245-
cd ../testdata
246-
# The *.mkv fixtures are gitignored, so they have to be encoded with ffmpeg. Generating
247-
# all of them takes ~10 minutes; the e2e only reads node1/disk1/{tv,movies}, so drop the
248-
# rest first and it takes ~4. CI caches the result and skips this entirely on a hit.
249-
rm -rf node2 node1/disk2 node1/disk1/music node1/disk1/books
250-
./create_mkv.sh
251-
cd -
252-
253-
cd .. # ci/kind-config.yaml mounts ./testdata
254-
kind create cluster --name ister-ci --config chart/ci/kind-config.yaml
255-
cd chart
256-
257-
kubectl create namespace ister
258-
kubectl apply -n ister -f ci/mock-oidc.yaml
259-
helm dependency build
260-
helm install ister . -n ister -f ci/values-ci.yaml --wait --timeout 12m
261-
262-
helm test ister -n ister --logs
263-
./ci/e2e.sh ister ister
264-
265-
kind delete cluster --name ister-ci
261+
make up # fixtures + kind cluster + mock-oidc + podcast-feed + chart install
262+
make e2e # the API e2e scenarios
263+
make player-e2e # the player's Flutter integration tests (needs ../player + flutter)
264+
make down # delete the kind cluster
266265
```

ci/e2e.sh

Lines changed: 33 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,36 @@
11
#!/usr/bin/env bash
22
#
3-
# End-to-end test: does a fresh install actually index media?
3+
# End-to-end test: does a fresh install actually serve media?
44
#
5-
# Mints a JWT at the mock issuer, triggers a library scan through the real GraphQL API,
6-
# and polls until the scanner has produced shows and movies. Everything goes through the
7-
# same path a real client uses, so it also covers the chart's OIDC_URL wiring and the
8-
# auth layer — not just the scanner.
5+
# Mints a JWT at the mock issuer and runs the scenario scripts in ci/e2e/ in order:
6+
# scanning every library type, podcast subscribe/refresh, HLS streaming with a real
7+
# transcode, epub resources + reading progress, search and watch status. Everything
8+
# goes through the same path a real client uses, so it also covers the chart's
9+
# OIDC_URL wiring and the auth layer — not just the scanner.
910
#
1011
# Usage: ci/e2e.sh [release] [namespace]
12+
# E2E_ONLY=<pattern> — run only scenario files matching the glob (e.g. E2E_ONLY=30-*)
13+
# E2E_SKIP=<pattern> — skip scenario files matching the glob
1114

1215
set -euo pipefail
1316

14-
RELEASE="${1:-ister}"
15-
NAMESPACE="${2:-ister}"
16-
SERVER_PORT=18080
17-
OIDC_PORT=18081
18-
TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-240}"
17+
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
1918

20-
API="http://localhost:${SERVER_PORT}/api"
21-
PIDS=()
19+
export RELEASE="${1:-ister}"
20+
export NAMESPACE="${2:-ister}"
21+
export SERVER_PORT=18080
22+
export OIDC_PORT=18081
23+
export API="http://localhost:${SERVER_PORT}/api"
2224

25+
PIDS=()
2326
cleanup() {
2427
for pid in "${PIDS[@]:-}"; do
2528
kill "$pid" 2>/dev/null || true
2629
done
2730
}
2831
trap cleanup EXIT
2932

30-
fail() { echo "FAIL: $*" >&2; exit 1; }
31-
32-
forward() { # svc port -> localhost port
33-
kubectl port-forward -n "$NAMESPACE" "svc/$1" "$3:$2" >/dev/null 2>&1 &
34-
PIDS+=($!)
35-
}
36-
37-
wait_for() { # url
38-
for _ in $(seq 1 30); do
39-
curl -fsS -o /dev/null "$1" 2>/dev/null && return 0
40-
sleep 1
41-
done
42-
return 1
43-
}
44-
45-
# GraphQL over HTTP. Passes the query as a JSON string via jq so quoting can't bite us.
46-
gql() { # query [token]
47-
local body auth=()
48-
body=$(jq -n --arg q "$1" '{query: $q}')
49-
[ -n "${2:-}" ] && auth=(-H "Authorization: Bearer $2")
50-
curl -fsS -X POST "$API/graphql" \
51-
-H 'Content-Type: application/json' \
52-
"${auth[@]}" \
53-
-d "$body"
54-
}
33+
source "$SCRIPT_DIR/e2e/lib.sh"
5534

5635
echo "==> Port-forwarding"
5736
forward "${RELEASE}-server" 8080 "$SERVER_PORT"
@@ -62,45 +41,31 @@ wait_for "http://localhost:${OIDC_PORT}/default/.well-known/openid-configuration
6241
|| fail "mock issuer not reachable on :${OIDC_PORT}"
6342

6443
echo "==> Minting a token with roles=[user]"
65-
# The Host header matters: mock-oauth2-server builds the `iss` claim from it, and the
66-
# server rejects any token whose `iss` differs from OIDC_URL (http://mock-oidc:8080/default).
67-
TOKEN=$(curl -fsS -X POST "http://localhost:${OIDC_PORT}/default/token" \
68-
-H 'Host: mock-oidc:8080' \
69-
-d grant_type=client_credentials \
70-
-d client_id=ci \
71-
-d client_secret=ci-secret \
72-
-d scope=ister | jq -r '.access_token')
73-
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || fail "no access_token from the mock issuer"
44+
mint_token
45+
export TOKEN
7446

7547
echo "==> Checking the token is actually accepted (authenticated query)"
7648
# If OIDC_URL, the issuer or the roles claim were wrong, this is where it shows up —
7749
# with a clear error, rather than as a mysteriously empty library later on.
78-
libraries=$(gql '{ libraries { id name type } }' "$TOKEN")
50+
libraries=$(gql '{ libraries { id name type } }')
7951
echo "$libraries" | jq -e '.errors' >/dev/null 2>&1 \
8052
&& fail "authenticated query rejected: $(echo "$libraries" | jq -c '.errors')"
8153
echo "$libraries" | jq -e '.data.libraries | length >= 2' >/dev/null \
82-
|| fail "expected the 2 configured libraries, got: $(echo "$libraries" | jq -c '.data.libraries')"
54+
|| fail "expected the configured libraries, got: $(echo "$libraries" | jq -c '.data.libraries')"
8355

84-
echo "==> Triggering the library scan"
85-
scan=$(gql 'mutation { scanLibrary }' "$TOKEN")
86-
echo "$scan" | jq -e '.data.scanLibrary == true' >/dev/null \
87-
|| fail "scanLibrary did not return true: $scan"
88-
89-
# The scan is asynchronous (RabbitMQ events, then ffprobe per file). Rows appear within
90-
# seconds, but poll rather than sleep — a fixed sleep is either flaky or slow.
91-
echo "==> Waiting for the scanner to index media (up to ${TIMEOUT_SECONDS}s)"
92-
deadline=$((SECONDS + TIMEOUT_SECONDS))
93-
shows=0
94-
movies=0
95-
while [ $SECONDS -lt $deadline ]; do
96-
shows=$(gql '{ shows(size: 1) { totalElements } }' "$TOKEN" | jq -r '.data.shows.totalElements // 0')
97-
movies=$(gql '{ movies(size: 1) { totalElements } }' "$TOKEN" | jq -r '.data.movies.totalElements // 0')
98-
echo " shows=$shows movies=$movies"
99-
if [ "$shows" -gt 0 ] && [ "$movies" -gt 0 ]; then
100-
echo "==> PASS: indexed $shows show(s) and $movies movie(s)"
101-
exit 0
56+
for scenario in "$SCRIPT_DIR"/e2e/[0-9]*.sh; do
57+
name=$(basename "$scenario")
58+
if [ -n "${E2E_ONLY:-}" ] && [[ "$name" != ${E2E_ONLY} ]]; then
59+
echo "==> Skipping $name (E2E_ONLY=${E2E_ONLY})"
60+
continue
61+
fi
62+
if [ -n "${E2E_SKIP:-}" ] && [[ "$name" == ${E2E_SKIP} ]]; then
63+
echo "==> Skipping $name (E2E_SKIP=${E2E_SKIP})"
64+
continue
10265
fi
103-
sleep 5
66+
echo "==> Scenario $name"
67+
# Scenarios run in this shell so they share TOKEN, API and the helpers.
68+
source "$scenario"
10469
done
10570

106-
fail "nothing indexed within ${TIMEOUT_SECONDS}s (shows=$shows movies=$movies)"
71+
echo "==> PASS: all scenarios completed"

ci/e2e/10-scan.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Scenario: scanning indexes every library type.
2+
#
3+
# Triggers the scan and polls until shows, movies, albums, books AND comic series all
4+
# have rows, then asserts the deeper structure the scanners are responsible for:
5+
# audiobook chapters, media-overlay detection on the read-aloud epub, comic page counts.
6+
7+
echo "--> Triggering the library scan"
8+
scan=$(gql 'mutation { scanLibrary }')
9+
echo "$scan" | jq -e '.data.scanLibrary == true' >/dev/null \
10+
|| fail "scanLibrary did not return true: $scan"
11+
12+
# The scan is asynchronous (RabbitMQ events, then ffprobe per file). Poll rather than
13+
# sleep — a fixed sleep is either flaky or slow.
14+
echo "--> Waiting for the scanner to index all media types (up to ${SCAN_TIMEOUT_SECONDS:-300}s)"
15+
scan_counts() {
16+
shows=$(gql '{ shows(size: 1) { totalElements } }' | jq -r '.data.shows.totalElements // 0')
17+
movies=$(gql '{ movies(size: 1) { totalElements } }' | jq -r '.data.movies.totalElements // 0')
18+
albums=$(gql '{ albums(size: 1) { totalElements } }' | jq -r '.data.albums.totalElements // 0')
19+
books=$(gql '{ books(size: 1) { totalElements } }' | jq -r '.data.books.totalElements // 0')
20+
series=$(gql '{ series(size: 1) { totalElements } }' | jq -r '.data.series.totalElements // 0')
21+
echo " shows=$shows movies=$movies albums=$albums books=$books series=$series"
22+
[ "$shows" -gt 0 ] && [ "$movies" -gt 0 ] && [ "$albums" -gt 0 ] \
23+
&& [ "$books" -gt 0 ] && [ "$series" -gt 0 ]
24+
}
25+
poll_until "${SCAN_TIMEOUT_SECONDS:-300}" "indexing all media types" scan_counts
26+
27+
echo "--> Asserting audiobook chapters"
28+
books_json=$(gql '{ books(size: 50) { content { name chapters { id } epubFiles { id mediaOverlays } } } }')
29+
echo "$books_json" | jq -e '.data.books.content | map(select(.chapters | length > 0)) | length > 0' >/dev/null \
30+
|| fail "no book with audiobook chapters found: $(echo "$books_json" | jq -c '.data.books.content')"
31+
32+
echo "--> Asserting media-overlay (read-aloud) detection"
33+
# "Spring Walk.epub" is a media-overlay epub deliberately named without any hint in the
34+
# filename, so this only passes when the scanner detects overlays from the contents.
35+
echo "$books_json" | jq -e '[.data.books.content[].epubFiles // [] | .[] | select(.mediaOverlays == true)] | length > 0' >/dev/null \
36+
|| fail "no epub with mediaOverlays=true found: $(echo "$books_json" | jq -c '.data.books.content')"
37+
38+
echo "--> Asserting comic volumes with pages"
39+
series_json=$(gql '{ series(size: 50) { content { name startYear books { name epubFiles { pageCount } } } } }')
40+
echo "$series_json" | jq -e '[.data.series.content[].books[].epubFiles // [] | .[] | select(.pageCount > 0)] | length > 0' >/dev/null \
41+
|| fail "no comic volume with pageCount > 0: $(echo "$series_json" | jq -c '.data.series.content')"
42+
echo "$series_json" | jq -e '.data.series.content | map(select(.startYear == 1998)) | length > 0' >/dev/null \
43+
|| fail "series start year (1998) not parsed: $(echo "$series_json" | jq -c '.data.series.content')"
44+
45+
echo "--> Scan scenario passed"

0 commit comments

Comments
 (0)