Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/scripts/compose_telegram_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
("sonarqube", "SONAR_BACKEND_RESULT"),
("sonarqube-frontend", "SONAR_FRONTEND_RESULT"),
("docker", "DOCKER_RESULT"),
("frontend-e2e-smoke", "FRONTEND_E2E_SMOKE_RESULT"),
)
TOTAL_LINE = "- Total: {value}"
SKIPPED_LINE = "- ⏭️ Skipped: {value}"
Expand Down Expand Up @@ -225,6 +226,7 @@ def needs_overall_status() -> str:
os.environ.get("SONAR_BACKEND_RESULT", "unknown"),
os.environ.get("SONAR_FRONTEND_RESULT", "unknown"),
os.environ.get("DOCKER_RESULT", "unknown"),
os.environ.get("FRONTEND_E2E_SMOKE_RESULT", "unknown"),
]
lowered = [r.lower() for r in tracked_results]

Expand All @@ -238,7 +240,7 @@ def needs_overall_status() -> str:
def _is_relevant_job(name: str) -> bool:
if name == "Notify Telegram":
return False
if name in ("Build common", "Frontend (Angular)", "Docker Build"):
if name in ("Build common", "Frontend (Angular)", "Docker Build", "Frontend E2E Smoke"):
return True
return name.startswith(("Unit - ", "Integration - ", SONAR_JOB_PREFIX))

Expand Down
63 changes: 61 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -452,11 +452,69 @@ jobs:
- name: Build frontend image
run: docker build -f frontend/Dockerfile -t frontend:ci frontend

# --- 8. Telegram notification -------------------------------------------------
# --- 8. Frontend E2E smoke (docker compose) -----------------------------------
frontend-e2e-smoke:
name: Frontend E2E Smoke
runs-on: ubuntu-latest
needs: [frontend]
permissions:
contents: read
actions: read

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Use Node 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install frontend dependencies
working-directory: frontend
run: npm ci --ignore-scripts

- name: Install Playwright Chromium deps
working-directory: frontend
run: npx playwright install --with-deps chromium

- name: Prepare .env for Compose
run: cp .env.example .env

- name: Start stack for E2E smoke
run: docker compose up -d --build

Comment thread
igorsatsyuk marked this conversation as resolved.
- name: Wait for frontend endpoint
shell: bash
run: |
for i in {1..60}; do
if curl -fsS http://localhost:4200 > /dev/null; then
echo "Frontend is up"
exit 0
fi
sleep 2
done
echo "Frontend did not become ready in time"
exit 1

- name: Run frontend E2E smoke
working-directory: frontend
run: npm run test:e2e-smoke
Comment thread
igorsatsyuk marked this conversation as resolved.

- name: Dump compose logs on failure
if: failure()
run: docker compose logs --tail=200

- name: Stop stack
if: always()
run: docker compose down -v

# --- 9. Telegram notification -------------------------------------------------
notify-telegram:
name: Notify Telegram
runs-on: ubuntu-latest
needs: [backend-unit, backend-integration, frontend, sonarqube, sonarqube-frontend, docker]
needs: [backend-unit, backend-integration, frontend, sonarqube, sonarqube-frontend, docker, frontend-e2e-smoke]
if: ${{ always() }}
env:
TELEGRAM_TO: ${{ secrets.TELEGRAM_TO }}
Expand Down Expand Up @@ -496,6 +554,7 @@ jobs:
SONAR_BACKEND_RESULT: ${{ needs.sonarqube.result }}
SONAR_FRONTEND_RESULT: ${{ needs.sonarqube-frontend.result }}
DOCKER_RESULT: ${{ needs.docker.result }}
FRONTEND_E2E_SMOKE_RESULT: ${{ needs.frontend-e2e-smoke.result }}

- name: Notify Telegram
if: ${{ env.TELEGRAM_TO != '' && env.TELEGRAM_TOKEN != '' }}
Expand Down
39 changes: 35 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,28 @@ npm run test:ci
npm run build --if-present
```

### Frontend E2E smoke

Verifies the full pipeline end-to-end: seeds search events, waits for Redis prefix index to populate, opens the UI in a headless browser, and asserts seeded suggestions order by score with correct click-through behavior.

Requires Docker Desktop running and `.env` populated (`Copy-Item .env.example .env`).

```powershell
# Stack must already be up
Set-Location .\frontend
npm run test:e2e-smoke
```

To start the stack, run, and tear down:

```powershell
docker compose up -d --build
Set-Location .\frontend
npm run test:e2e-smoke
Set-Location ..
docker compose down -v
```

### End-to-end sanity checks

```powershell
Expand All @@ -205,22 +227,30 @@ docker compose logs --tail=100 cdc-service
docker compose exec redis redis-cli ZREVRANGE autocomplete:ja 0 9 WITHSCORES
```

For a fully automated end-to-end check, use the smoke test:

```powershell
Set-Location .\frontend
npm run test:e2e-smoke
```

---

## Pull Request Process

1. Create a branch from `main`.
2. Implement changes with tests.
3. Run relevant backend/frontend checks locally.
4. Verify docs/configs are in sync when needed:
4. Run E2E smoke test if pipeline behavior was affected: `npm run test:e2e-smoke` from `frontend/`.
5. Verify docs/configs are in sync when needed:
- migrations + `infra/postgres`
- `proxy.conf.json` + `nginx.conf`
5. Open a PR to `main` with:
6. Open a PR to `main` with:
- change summary
- affected pipeline stage(s)
- test evidence
6. Address review comments.
7. Merge after CI passes.
7. Address review comments.
8. Merge after CI passes (including `frontend-e2e-smoke` job).

---

Expand All @@ -232,6 +262,7 @@ docker compose exec redis redis-cli ZREVRANGE autocomplete:ja 0 9 WITHSCORES
- [ ] Debezium parsing uses envelope (`payload.after`)
- [ ] Schema changes mirrored in both migration locations
- [ ] Relevant unit/integration/frontend tests are updated
- [ ] E2E smoke passes locally (`npm run test:e2e-smoke`) if pipeline behavior changed
- [ ] `frontend/proxy.conf.json` and `frontend/nginx.conf` are aligned (if routes changed)
- [ ] No secrets committed; logs/examples are redacted
- [ ] Commit messages follow Conventional Commits
Expand Down
60 changes: 56 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,51 @@ npm run test
npm run build
```

Frontend test references:
- Unit tests: `frontend/src/app/**/*.spec.ts`
- E2E smoke script: `frontend/scripts/e2e-smoke.js`

### Frontend E2E Smoke Test

End-to-end smoke test that validates the full pipeline (search -> Kafka/CDC -> Redis -> autocomplete API -> UI). The script itself assumes the stack is already running.

**Prerequisites:** Docker Desktop running, `.env` populated (copy from `.env.example`).

```powershell
# One-command local run (requires the stack to already be up)
Set-Location .\frontend
npm run test:e2e-smoke
```

To start the stack first and then run the test:

```powershell
docker compose up -d --build
Set-Location .\frontend
npm run test:e2e-smoke
Comment thread
igorsatsyuk marked this conversation as resolved.
Set-Location ..
docker compose down -v
```

The script (`frontend/scripts/e2e-smoke.js`):
- Waits for Debezium connector readiness (`RUNNING`) before seeding events.
- Seeds unique search queries via `/api/search`.
- Polls `/api/complete` until suggestions appear (up to 180 s) to tolerate fresh-stack warm-up.
- Opens the UI in Playwright headless Chromium, types the prefix, asserts seeded suggestions are rendered in descending score order.
- Clicks the top suggestion and verifies it is copied to the input field.

`FRONTEND_URL` environment variable overrides the default `http://localhost:4200`:

```powershell
$env:FRONTEND_URL = "http://localhost:4200"; npm run test:e2e-smoke
```

`DEBEZIUM_STATUS_URL` can override the default connector status endpoint (`http://localhost:8083/connectors/postgres-connector/status`):

```powershell
$env:DEBEZIUM_STATUS_URL = "http://localhost:8083/connectors/postgres-connector/status"; npm run test:e2e-smoke
```

## CI Pipeline

GitHub Actions workflow: `.github/workflows/ci.yml` runs on every push to `main` and on pull requests.
Expand Down Expand Up @@ -257,8 +302,14 @@ GitHub Actions workflow: `.github/workflows/ci.yml` runs on every push to `main`
- Does not push; useful for validating builds on PRs.
- Runs after backend-unit, backend-integration, and frontend complete.

8. **notify-telegram** (final stage)
- Sends a Telegram notification with overall CI status.
8. **frontend-e2e-smoke**
- Runs after the `frontend` job; requires Docker Compose.
- Starts the full stack (`docker compose up -d --build`), waits for `http://localhost:4200`.
- Runs `npm run test:e2e-smoke` from `frontend/` — seeds search events, polls autocomplete API, verifies seeded suggestions order by score and click-through behavior.
- Dumps compose logs on failure; always tears down with `docker compose down -v`.

9. **notify-telegram** (final stage)
- Sends a Telegram notification with overall CI status including `frontend-e2e-smoke` result.
- Requires `TELEGRAM_TO` and `TELEGRAM_TOKEN` secrets; skips silently if unavailable.
- Runs after all other jobs, regardless of their result.

Expand Down Expand Up @@ -421,8 +472,9 @@ docker compose exec kafka kafka-topics --bootstrap-server kafka:9092 --list

1. Run backend unit tests: `mvn -B test` from each service directory.
2. Run frontend tests: `npm run test:ci` from `frontend/`.
3. Verify build: `docker compose build` (or individual `docker build` commands).
4. Check code formatting via SonarQube locally if possible, or rely on CI feedback.
3. Run E2E smoke test if pipeline behavior was affected: `npm run test:e2e-smoke` from `frontend/` (requires stack to be up).
4. Verify build: `docker compose build` (or individual `docker build` commands).
5. Check code formatting via SonarQube locally if possible, or rely on CI feedback.

### Operational Checklist

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ public Flux<AutocompleteEntry> suggest(String prefix, int limit) {

Range<Long> range = Range.closed(0L, (long) limit - 1);
return redisTemplate.opsForZSet()
.reverseRange(redisPrefix + normalizedPrefix, range)
.map(q -> new AutocompleteEntry(q, 0.0));
.reverseRangeWithScores(redisPrefix + normalizedPrefix, range)
.filter(tuple -> tuple.getValue() != null)
.map(tuple -> new AutocompleteEntry(tuple.getValue(), tuple.getScore() == null ? 0.0 : tuple.getScore()));
}

private static String normalizePrefix(String raw) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package lt.satsyuk.autocomplete.integration;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
Expand All @@ -22,6 +24,8 @@
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AutocompleteServiceRedisIT {

private static final String AUTOCOMPLETE_KEY = "autocomplete:ja";

@Container
static final GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.2-alpine"))
.withExposedPorts(6379);
Expand All @@ -39,7 +43,8 @@ static void configure(DynamicPropertyRegistry registry) {
@Test
void completeReturnsEntriesFromRedisSortedSet() {
try (Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379))) {
jedis.zadd("autocomplete:ja", 5.0d, "java");
jedis.del(AUTOCOMPLETE_KEY);
jedis.zadd(AUTOCOMPLETE_KEY, 5.0d, "java");
}

HttpRequest request = HttpRequest.newBuilder()
Expand All @@ -51,9 +56,38 @@ void completeReturnsEntriesFromRedisSortedSet() {
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("\"query\":\"java\"");
assertThat(response.body()).contains("\"score\":5.0");
} catch (Exception e) {
throw new RuntimeException(e);
}
}

@Test
void completeReturnsStrictJsonContract() throws Exception {
try (Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379))) {
jedis.del(AUTOCOMPLETE_KEY);
jedis.zadd(AUTOCOMPLETE_KEY, 5.0d, "java");
jedis.zadd(AUTOCOMPLETE_KEY, 3.0d, "javascript");
}
Comment thread
igorsatsyuk marked this conversation as resolved.

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/complete?q=ja&limit=2"))
.GET()
.build();

HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);

ObjectMapper objectMapper = new ObjectMapper();
JsonNode actual = objectMapper.readTree(response.body());
JsonNode expected = objectMapper.readTree("""
[
{"query":"java","score":5.0},
{"query":"javascript","score":3.0}
]
""");

assertThat(actual).isEqualTo(expected);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.DefaultTypedTuple;
import org.springframework.data.domain.Range;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.data.redis.core.ReactiveZSetOperations;
Expand Down Expand Up @@ -37,15 +38,18 @@ void setUp() {
@Test
void returnsSuggestionsFromConfiguredRedisPrefixWithTrimmedLowercaseKey() {
when(redisTemplate.opsForZSet()).thenReturn(zSetOperations);
when(zSetOperations.reverseRange(eq("autocomplete:ja"), ArgumentMatchers.<Range<Long>>any()))
.thenReturn(Flux.just("java", "javascript"));
when(zSetOperations.reverseRangeWithScores(eq("autocomplete:ja"), ArgumentMatchers.<Range<Long>>any()))
.thenReturn(Flux.just(
new DefaultTypedTuple<>("java", 7.0),
new DefaultTypedTuple<>("javascript", 3.0)
));

StepVerifier.create(service.suggest(" Ja ", 2))
.expectNext(new AutocompleteEntry("java", 0.0))
.expectNext(new AutocompleteEntry("javascript", 0.0))
.expectNext(new AutocompleteEntry("java", 7.0))
.expectNext(new AutocompleteEntry("javascript", 3.0))
.verifyComplete();

verify(zSetOperations).reverseRange(eq("autocomplete:ja"), ArgumentMatchers.<Range<Long>>any());
verify(zSetOperations).reverseRangeWithScores(eq("autocomplete:ja"), ArgumentMatchers.<Range<Long>>any());
}

@Test
Expand Down
Loading
Loading