diff --git a/.github/skills/functional-tests-skill/SKILL.md b/.github/skills/functional-tests-skill/SKILL.md new file mode 100644 index 0000000..3eab82d --- /dev/null +++ b/.github/skills/functional-tests-skill/SKILL.md @@ -0,0 +1,1301 @@ +--- +name: functional-tests-skill +description: Expert testing skill for the Levi9 functional-tests project — a Java 23 / Maven / Cucumber BDD framework using REST Assured for API tests and Selenium 4 for UI tests with Spring DI. Use this skill whenever the user wants to write, create, add, extend, debug, review, or refactor any kind of test in this project. Trigger when the user mentions writing tests, adding test coverage, creating feature files, Gherkin scenarios, step definitions, stepdefs, REST API tests, UI tests, Selenium, page objects, debugging failing tests, fixing test failures, test architecture improvements, generating test data, reviewing test code, fetching Jira tickets, fetching GitHub issues, creating tests from Jira/GitHub/board tickets, or anything related to testing in this Cucumber/REST Assured/Selenium codebase. Even if the user just says "write a test" or "add coverage for X" or "this test is failing" or "write tests for PROJ-123" or "write tests for owner/repo#42", this skill should activate immediately. +--- + +# Functional Tests Skill + +This skill guides you in working with the **Levi9 functional-tests** project — a Java 23 / Maven / Cucumber BDD framework that tests two systems: +- **Pet Store API** (REST API tests only) — uses hand-crafted DSO classes for request/response bodies +- **Restful Booker Platform** (REST API + UI tests) — uses OpenAPI-generated models from `restfulbooker.model.*` for request/response bodies, plus a few hand-crafted DSOs + +## Core Principles + +1. **Follow existing patterns exactly** — Study similar tests in the codebase before creating new ones. Match naming conventions, package structure, coding style, and architectural patterns. + +2. **Run tests after implementation** — After creating or modifying tests, ALWAYS run them to verify they work. If they fail, investigate and fix the root cause. + +3. **Comprehensive coverage** — Create at least one happy path scenario, one rainy day scenario, and relevant edge cases that make sense for the feature. + +4. **Readable and reusable** — Write clean, self-documenting code with minimal effort needed for maintenance. Use descriptive names, clear Gherkin, and well-structured step definitions. + +5. **Consistency is key** — The code you generate should be indistinguishable from existing code in terms of style, patterns, and practices. + +## Project Architecture + +### Layer Structure + +``` +Feature Files (.feature) + ↓ +Step Definitions (@Given/@When/@Then) + ↓ +Service Layer (Business logic) + ↓ +REST Clients (BaseRestClient + specific) | UI Pages (BasePage + specific) + ↓ +DSOs / OpenAPI Models | Storage (Entity classes) +``` + +### Key Packages + +``` +src/main/java/com/levi9/functionaltests/ +├── exceptions/ # FunctionalTestsException +├── rest/ +│ ├── client/ # BaseRestClient, PetStoreRestClient, RestfulBookerRestClient, RandomDogRestClient +│ ├── data/ # DSO classes, enums, helper classes +│ │ ├── petstore/ # PetDSO, OrderDSO, CategoryDSO, TagDSO, MessageDSO, PetStatus, OrderStatus +│ │ ├── restfulbooker/ # BookingsDSO, RoomAmenities, RoomType +│ │ └── randomdogimage/ # RandomDogImageDSO +│ └── service/ # Service layer (@Component with business logic) +│ ├── petstore/ # PetService, StoreOrderService +│ ├── restfulbooker/ # AuthService, BookingService, RoomService +│ └── randomdogimage/ # RandomDogImageService +├── storage/ # Storage.java + Entity classes +│ ├── ScenarioEntity # Test scenario metadata + embed helpers +│ └── domain/ +│ ├── petstore/ # PetEntity, OrderEntity +│ └── restfulbooker/ # RoomEntity +├── ui/ +│ ├── base/ # BaseDriver, BaseDriverListener, BasePage, Browser enum +│ ├── helpers/ # WaitHelper, ActionsHelper, UploadHelper +│ └── pages/ +│ └── restfulbooker/ # AdminPage, BannerPage, FrontPage, HeaderPage, RoomsPage +└── util/ # FakeUtil (random emails/phones), FileUtil + +src/test/java/com/levi9/functionaltests/ +├── config/ # SpringConfig (@PropertySource, @ComponentScan) +├── hooks/ # Hooks.java (@Before/@After with ordering) +├── runners/ # DryRunRunnerIT (validation) +├── stepdefs/ # Step definitions by domain +│ ├── petstore/ # PetStepdef, StoreStepdef +│ └── restfulbooker/ # LoginStepdef, RoomManagementStepdef, BookingStepdef, ContactStepdef +└── typeregistry/ # ParameterTypes.java — custom Cucumber parameter types + +src/test/resources/features/ +├── pet-store/ # Pet Store features +│ ├── pet.feature +│ └── store.feature +└── restful-booker-platform/ # Restful Booker features + ├── admin-panel/ + │ ├── login.feature + │ └── room-management.feature + └── front-page/ + ├── book-a-room.feature + ├── book-a-room-invalid-validation.feature + ├── contact-hotel.feature + └── contact-hotel-invalid-validation.feature +``` + +### Generated Models vs Hand-Crafted DSOs + +This is a critical distinction: + +**Pet Store** uses hand-crafted DSOs in `com.levi9.functionaltests.rest.data.petstore`: +```java +PetDSO, OrderDSO, CategoryDSO, TagDSO, MessageDSO +``` + +**Restful Booker** uses OpenAPI-generated models in `restfulbooker.model.*`: +```java +restfulbooker.model.room.Room, Rooms +restfulbooker.model.auth.Auth, Token +restfulbooker.model.booking.Booking, Bookings +// etc. +``` +Plus a few hand-crafted classes in `com.levi9.functionaltests.rest.data.restfulbooker`: +```java +BookingsDSO, RoomAmenities, RoomType +``` + +When creating new Restful Booker tests, prefer using the generated models for API request/response bodies. When creating Pet Store tests, create hand-crafted DSOs. + +## Test Types + +### REST API Tests (`@api`) + +API tests use **REST Assured** via service layer pattern. Services are Spring `@Component` beans with `@Scope("cucumber-glue")`. + +**Tags**: `@api`, plus domain tags like `@pet`, `@store`, `@room`, `@booking`, etc. + +### UI Tests (`@ui`) + +UI tests use **Selenium 4** via Page Object pattern. All pages extend `BasePage` which provides wait-based interactions. + +**Tags**: `@ui`, plus domain tags like `@login`, `@management`, `@room-management`, `@booking`, `@contact`, etc. + +### Tags Reference + +| Tag | Purpose | +|-----|---------| +| `@api` | REST API test | +| `@ui` | UI/Selenium test | +| `@pet`, `@store`, `@login`, `@management`, `@room-management`, `@booking`, `@contact` | Domain/feature tags | +| `@blocker` | Highest severity — core functionality | +| `@critical` | High severity — important flows | +| `@normal` | Standard severity — expected behavior | +| `@minor` | Low severity — edge cases | +| `@sanity` | Sanity suite — quick smoke tests | +| `@bug` | Known bug — excluded from default runs | +| `@pdf`, `@html`, `@image` | Embedding type tags (for report artifacts) | + +### Hooks (Lifecycle) + +Hooks in `src/test/java/com/levi9/functionaltests/hooks/Hooks.java` manage test lifecycle: + +``` +@Before(order = 0) → Scenario start (all tests) — sets scenario metadata +@Before(value = "@ui", order = 1) → WebDriver setup (UI only) — initialize, set timeouts, maximize +@After(value = "@ui", order = 3) → Screenshot on failure (UI only) +@After(order = 2) → Cleanup — deletes created rooms via API +@After(value = "@ui", order = 1) → WebDriver teardown (UI only) — close browser +@After(order = 0) → Scenario end logging (all tests) +``` + +**Key**: Cleanup hooks delete test data (rooms) automatically. No manual cleanup needed in tests. + +## Common Patterns + +### 1. Service Layer Pattern (REST API Tests) + +Services encapsulate business logic, make REST calls, validate responses, and update Storage. Services use **constructor injection** (not field `@Autowired`) and **throw `FunctionalTestsException`** on bad status codes (not AssertJ assertions). + +**Real example from PetService:** +```java +@Slf4j +@Component +@Scope("cucumber-glue") +public class PetService { + + public static final String REST_PATH = "v2/pet/"; + + private final PetStoreRestClient petStoreRestClient; + private final Storage storage; + + @Autowired + public PetService(final PetStoreRestClient petStoreRestClient, final Storage storage) { + this.petStoreRestClient = petStoreRestClient; + this.storage = storage; + } + + public void addPetToStore(final String petName) { + final PetDSO body = PetDSO.builder() + .id(ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE)) + .name(petName) + .status(PENDING.getValue()) + .build(); + + final Response response = petStoreRestClient.post(body, null, REST_PATH); + if (response.statusCode() != HttpStatus.SC_OK) { + throw new FunctionalTestsException("Pet can not be added. Expected {}, but actual {}. Response message: {}", + HttpStatus.SC_OK, response.statusCode(), response.getBody().prettyPrint()); + } + + final PetEntity pet = PetEntity.builder() + .id(body.getId()) + .name(body.getName()) + .status(PENDING) + .build(); + + storage.getPets().add(pet); + } +} +``` + +**Real example from RoomService (uses OpenAPI generated model):** +```java +@Slf4j +@Component +@Scope("cucumber-glue") +public class RoomService { + + public static final String REST_PATH = "room/"; + + private final RestfulBookerRestClient restfulBookerRestClient; + private final BookingService bookingService; + private final Storage storage; + + @Autowired + public RoomService(final RestfulBookerRestClient restfulBookerRestClient, final BookingService bookingService, final Storage storage) { + this.restfulBookerRestClient = restfulBookerRestClient; + this.bookingService = bookingService; + this.storage = storage; + } + + public void createRoom(final String roomName, final RoomType roomType, final boolean accessible, + final String roomPrice, final RoomAmenities roomAmenities) { + + // Uses OpenAPI-generated Room model, not a hand-crafted DSO + final Room body = Room.builder() + .roomName(roomName) + .roomPrice(Integer.parseInt(roomPrice)) + .type(roomType.getValue()) + .description("Created with Java Cucumber E2E Test Automation Framework") + .accessible(accessible) + .features(roomAmenities.getAmenitiesAsList()) + .image(getImageUrl(roomType)) + .build(); + + final Response response = restfulBookerRestClient.post(body, null, REST_PATH); + if (response.statusCode() != HttpStatus.SC_CREATED) { + throw new FunctionalTestsException("Room can not be created. Expected {}, but actual {}. Response message: {}", + HttpStatus.SC_OK, response.statusCode(), response.getBody().prettyPrint()); + } + + final Room createdRoom = response.as(Room.class); + final RoomEntity roomEntity = new RoomEntity(body); + roomEntity.setRoomId(createdRoom.getRoomid()); + + storage.getRooms().add(roomEntity); + } +} +``` + +**Key patterns for services:** +- Always use `@Slf4j`, `@Component`, `@Scope("cucumber-glue")` +- **Constructor injection** with `@Autowired` on constructor, storing dependencies in `private final` fields +- **Throw `FunctionalTestsException`** on unexpected status codes — do NOT use AssertJ assertions in services +- Use `HttpStatus.SC_OK`, `HttpStatus.SC_CREATED`, `HttpStatus.SC_ACCEPTED`, etc. from `org.apache.http.HttpStatus` +- Use `response.getBody().prettyPrint()` in exception messages for debugging +- `public static final String REST_PATH` constant for endpoint base path +- Update Storage with entities after successful operations +- Log important actions and results + +### 2. Page Object Pattern (UI Tests) + +Page objects extend `BasePage` and use wait-based Selenium interactions. + +**Real example from AdminPage:** +```java +@Slf4j +@Component +@Scope("cucumber-glue") +public class AdminPage extends BasePage { + + private final By page = By.xpath("//*[@data-testid='login-header']"); + private final By usernameField = By.id("username"); + private final By passwordField = By.id("password"); + private final By loginButton = By.id("doLogin"); + + protected AdminPage(final BaseDriver baseDriver) { + super(baseDriver); + } + + public boolean isLoaded() { + return isElementVisible(page, 5); + } + + public void load() { + openPage(getRestfulBookerPlatformUrl() + "#/admin", page); + } + + public void login(final String username, final String password) { + waitAndSendKeys(usernameField, username); + waitAndSendKeys(passwordField, password); + waitAndClick(loginButton); + log.info("Login via UI using username: '{}' and password '{}'", username, password); + } +} +``` + +**Key points**: +- Extend `BasePage` where `T` is the page class itself +- Constructor takes `BaseDriver`, calls `super(baseDriver)` — `@Autowired` on constructors is optional (Spring infers single-constructor injection) +- **Every page MUST have**: `isLoaded()` (calls `isElementVisible(pageLocator, 5)`) and `load()` (calls `openPage(url, pageLocator)`) +- Use `getRestfulBookerPlatformUrl()` for base URL (provided by BasePage via `@Value`) +- Use descriptive locator names as `private final By` fields +- Use wait-based methods from BasePage: `waitAndClick()`, `waitAndSendKeys()`, `waitAndSelectByValue()`, `waitAndSelectByVisibleText()`, `waitAndSelectByIndex()`, `waitAndGetText()`, `waitAndGetAttribute()`, `waitAndGetWebElement()`, etc. +- **Return type**: Use `void` for action methods (this project's convention, not fluent API) +- Use `@Nullable` (from `javax.annotation.Nullable`) on parameters for methods used in negative/validation tests (skip interaction when null) +- Error messages: retrieve via `findElements(By.cssSelector("p")).stream().map(WebElement::getText).toList()` +- Log actions for debugging +- For drag-and-drop use `getActionsHelper().dragAndDrop(fromElement, toElement)` (from BasePage) + +### 3. BaseRestClient Method Signatures + +All REST clients extend `BaseRestClient`. The **actual** method signatures are: + +```java +Response post(Object body, Map parameters, String path) +Response put(Object body, Map parameters, String path) +Response get(Map parameters, String path) +Response delete(Map parameters, String path) +Response uploadFile(File file, Map parameters, String path) +``` + +Pass `null` for `parameters` when query parameters are not needed. Pass `null` for `body` when no request body is needed. + +**Example with query parameters (from BookingService):** +```java +final Map parameters = new HashMap<>(); +parameters.put("roomId", Integer.toString(room.getRoomId())); +final Response response = restfulBookerRestClient.get(parameters, REST_PATH); +``` + +**Example without parameters (most common):** +```java +final Response response = petStoreRestClient.post(body, null, REST_PATH); +final Response response = petStoreRestClient.get(null, REST_PATH + pet.getId()); +final Response response = petStoreRestClient.delete(null, REST_PATH + pet.getId()); +``` + +### 4. REST Client Pattern + +REST clients are simple wrappers that extend `BaseRestClient` and pass the base URL from properties: + +```java +@Component +@Scope("cucumber-glue") +public class PetStoreRestClient extends BaseRestClient { + + public PetStoreRestClient(@Value("${pet-store.url}") final String serviceUrl) { + super(serviceUrl); + } +} +``` + +Base URLs are defined in `src/test/resources/application-{env}.properties`: +```properties +restful-booker-platform.url=http://localhost/ +pet-store.url=https://petstore.swagger.io/ +random.dog.url=https://random.dog/ +``` + +### 5. DSO (Data Service Object) Pattern + +DSOs are request/response DTOs using Lombok. Used for Pet Store and some Restful Booker classes. + +**Template**: +```java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class {Action}{Resource}DSO { + + @JsonProperty("field_name") + private String fieldName; + + private Integer count; + + private List items; +} +``` + +**Key points**: +- Use Lombok annotations for boilerplate reduction +- Use `@JsonProperty` when JSON field names differ from Java conventions +- Use `@Builder(toBuilder = true)` for immutability patterns + +### 6. Enum Pattern + +Enums follow a consistent pattern with a `String` value and a `getEnum()` factory method: + +```java +public enum RoomType { + SINGLE("Single"), + TWIN("Twin"), + DOUBLE("Double"), + FAMILY("Family"), + SUITE("Suite"); + + @Getter(AccessLevel.PUBLIC) + private final String value; + + RoomType(final String value) { + this.value = value; + } + + public static RoomType getEnum(final String value) { + for (final RoomType roomType : values()) { + if (roomType.getValue().equals(value)) { + return roomType; + } + } + throw new FunctionalTestsException("Room Type Enum with value {} not found!", value); + } +} +``` + +### 7. Entity Pattern + +Entities represent test-side domain objects stored in Storage. + +**Pet Store entities use builder pattern:** +```java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class PetEntity { + + private Integer id; + private CategoryDSO category; + private String name; + private List photoUrls; + private List tags; + private PetStatus status; + @Default + private boolean deleted = false; +} +``` + +**Restful Booker entities may have a constructor from generated model:** +```java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class RoomEntity { + + private Integer roomId; + private String roomName; + private Integer roomPrice; + private String description; + private RoomType type; + private Boolean accessible; + private String image; + private RoomAmenities amenities; + + // Constructor from OpenAPI-generated model + public RoomEntity(final Room room) { + this.roomId = room.getRoomid(); + this.roomName = room.getRoomName(); + // ... mapping from generated model fields + } +} +``` + +### 8. Storage Pattern + +`Storage` is a Spring `@Component` with `@Scope("cucumber-glue")` that maintains test state. It uses Lombok `@Getter` to auto-generate getters — there are no explicit getter or add methods. + +**Current Storage fields:** +```java +@Getter +@Component +@Scope("cucumber-glue") +public class Storage { + + // Test Scenario + private final ScenarioEntity testScenario = new ScenarioEntity(); + + // Used for REST API tests + private final List pets = new ArrayList<>(); + private final List orders = new ArrayList<>(); + + // Used for UI tests + private final List rooms = new ArrayList<>(); + + public PetEntity getLastPet() { + return pets.stream().reduce((first, last) -> last) + .orElseThrow(() -> new FunctionalTestsException("Last Pet not found!")); + } + + public OrderEntity getLastOrder() { /* same pattern */ } + public RoomEntity getLastRoom() { /* same pattern */ } +} +``` + +**Adding a new entity to Storage**: + +1. Add a field: `private final List<{Resource}Entity> {resource}s = new ArrayList<>();` +2. Lombok `@Getter` auto-generates `get{Resource}s()` — do NOT add explicit getters. +3. Add convenience method: +```java +public {Resource}Entity getLast{Resource}() { + return {resource}s.stream().reduce((first, last) -> last) + .orElseThrow(() -> new FunctionalTestsException("Last {Resource} not found!")); +} +``` + +**How callers add entities** (no dedicated add methods exist): +```java +storage.get{Resource}s().add(entity); +``` + +### 9. ScenarioEntity — Embedding Artifacts + +`ScenarioEntity` provides methods to embed artifacts into Cucumber reports. Step definitions call these to attach PDFs, images, or HTML: + +```java +storage.getTestScenario().embedPdfToScenario(); // Embeds a static dummy PDF +storage.getTestScenario().embedPicture(imageUrl); // Embeds an image from URL +storage.getTestScenario().embedHtml(htmlContent); // Embeds HTML content +``` + +These are used in stepdefs with the `@pdf`, `@image`, `@html` tags to demonstrate report embedding. + +### 10. Step Definitions + +Step definitions are in `src/test/java/com/levi9/functionaltests/stepdefs/{system}/`. + +This project uses **two step annotation styles**: + +#### Style A: Cucumber Expressions (Restful Booker tests) + +Used with `{string}`, `{int}`, and custom types like `{roomType}`, `{accessible}`. Gherkin uses **single quotes** for `{string}` values. Supports alternative text with `/` (e.g., `Validation/Mandatory`). + +```java +@Slf4j +public class RoomManagementStepdef { + + @Autowired + private Storage storage; + + @Autowired + private RoomService roomService; + + @Autowired + private RoomsPage roomsPage; + + @Autowired + private BannerPage bannerPage; + + @Given("User has created {roomType} type {accessible} room {string} priced at {int} GBP with {string}") + public void userCreatedRoom(final RoomType roomType, final boolean accessible, final String roomName, final int roomPrice, final String features) { + final RoomAmenities roomAmenities = new RoomAmenities(features); + roomService.createRoom(roomName, roomType, accessible, Integer.toString(roomPrice), roomAmenities); + } + + @Then("User will get validation/mandatory error message: {string}") + public void assertValidationOrMandatoryErrorMessage(final String message) { + assertThat(roomsPage.getValidationOrMandatoryErrorMessages()).as("Message '" + message + "' is not displayed!").contains(message); + log.info("Room Creation Validation / Mandatory Error Message '{}' is displayed", message); + } +} +``` + +Matching Gherkin (single quotes): +```gherkin +Given User has created Single type Accessible room '1408' priced at 50 GBP with 'WiFi, TV and Safe' +Then User will get mandatory error message: 'Room name must be set' +``` + +**Cucumber Expression alternative text**: Use `/` to match either word in Gherkin. Example: +- Step: `@Then("Visitor will get Booking Validation/Mandatory Error Message: {string}")` +- Gherkin: `Then Visitor will get Booking Validation Error Message: 'some error'` +- Or: `Then Visitor will get Booking Mandatory Error Message: 'some error'` + +**Cucumber Expression optional text**: Use `(invalid )` (with space inside parentheses) to optionally match text: +- Step: `@When("Visitor {string} {string} with an (invalid )email {string} and phone number {string} tries to book a room {string}")` +- Matches both: `with an email 'x@y.com'` and `with an invalid email 'bad'` + +#### Style B: Regex patterns (Pet Store tests) + +Used with `^...$` anchors, `"([^"]*)"` or `(.*)` for captures. Gherkin uses **double quotes** for string values. + +```java +@Slf4j +public class PetStepdef { + + @Autowired + private Storage storage; + + @Autowired + private PetService petService; + + @Given("^[Uu]ser add(?:s|ed) pet \"(.*)\" to the pet store$") + public void addPet(final String petName) { + petService.addPetToStore(petName); + log.info("Pet " + petName + " added to the store."); + } + + @When("^[Pp]et status is set to \"(available|pending|sold)\"$") + public void setPetStatus(final String petStatus) { + final PetEntity pet = storage.getLastPet(); + final PetStatus status = PetStatus.getEnum(petStatus); + petService.updatePetStatus(pet, status); + log.info("Pet status is set to " + petStatus); + } + + @Then("^[Ii]t (?:will be|is)? possible to sell it$") + public void validatePossibleToSell() { + final PetEntity expectedPet = storage.getLastPet(); + final PetDSO actualPet = petService.getPet(expectedPet); + assertThat(actualPet.getStatus()).as("Pet is not available!").isEqualTo(AVAILABLE.getValue()); + } +} +``` + +**Both styles are equally valid.** Match the style of the system you're extending. + +**Pet Store regex patterns**: Use `[Uu]`, `[Pp]`, `[Ii]`, `[Oo]`, `[Rr]` for case-insensitive first letter. Use `(?:s|ed)` for tense flexibility. Use `(?:will be|is)?` for assertion flexibility. + +### 11. Custom Parameter Types + +Define custom parameter types in `src/test/java/com/levi9/functionaltests/typeregistry/ParameterTypes.java`. + +**Current types:** +```java +public class ParameterTypes { + + @ParameterType("Single|Twin|Double|Family|Suite") + public RoomType roomType(final String roomType) { + return RoomType.getEnum(roomType); + } + + @ParameterType("Accessible|Not Accessible") + public boolean accessible(final String accessible) { + return !accessible.toLowerCase().contains("not"); + } +} +``` + +This allows Gherkin steps like: `When user creates a Single type Accessible room` where `Single` → `RoomType.SINGLE` and `Accessible` → `true`. + +### 12. FunctionalTestsException + +The project's custom exception uses SLF4J-style `{}` placeholder formatting: + +```java +throw new FunctionalTestsException("Order with ID {} not found!", orderId); +throw new FunctionalTestsException("Expected status {} but got {}", expectedStatus, actualStatus); +``` + +Also supports wrapping another exception: +```java +throw new FunctionalTestsException(e); +``` + +### 13. Soft Assertions + +Use `assertSoftly` for multi-field validations — all assertions run even if early ones fail: + +```java +assertSoftly(softly -> { + softly.assertThat(actualRoom.getName()).as("Room Name is wrong!").isEqualTo(expectedName); + softly.assertThat(actualRoom.getType()).as("Room Type is wrong!").isEqualTo(expectedType); + softly.assertThat(actualRoom.getPrice()).as("Room Price is wrong!").isEqualTo(expectedPrice); +}); +``` + +### 14. FakeUtil and RandomStringUtils + +For generating test data, use the existing utility classes: + +**FakeUtil** (`com.levi9.functionaltests.util.FakeUtil`): +```java +FakeUtil.getRandomEmail() // e.g., "abcXyz123@mail.com" +FakeUtil.getRandomPhoneNumber() // e.g., "123-456-7890" +``` + +**RandomStringUtils** (`org.apache.commons.lang3.RandomStringUtils`) — used directly in stepdefs: +```java +RandomStringUtils.randomAlphabetic(firstNameLength) // Random letters +RandomStringUtils.randomAlphanumeric(200) // Random letters+digits +RandomStringUtils.randomNumeric(phoneNumberLength) // Random digits +``` + +### 15. RoomAmenities Helper + +`RoomAmenities` is a helper class that converts between string representations and structured amenity data: + +```java +// From comma-separated string (used in Gherkin steps) +final RoomAmenities amenities = new RoomAmenities("WiFi, TV and Safe"); +amenities.isWifi(); // true +amenities.isTv(); // true +amenities.isSafe(); // true + +// To list of strings (for API requests) +amenities.getAmenitiesAsList(); // ["WiFi", "TV", "Safe"] + +// To display string (for UI assertions) +amenities.getRoomDetailsFromAmenities(); // "WiFi, TV, Safe" or "No features added to the room" +``` + +## Naming Conventions + +| Artifact | Pattern | Example | +|----------|---------|---------| +| **Feature files** | `kebab-case.feature` | `room-management.feature` | +| **Step defs** | `{Domain}Stepdef` | `RoomManagementStepdef` | +| **Services** | `{Domain}Service` | `RoomService`, `PetService` | +| **REST clients** | `{System}RestClient` | `RestfulBookerRestClient` | +| **DSOs** | `{Action}{Resource}DSO` or `{Resource}DSO` | `CreateRoomDSO`, `PetDSO` | +| **Entities** | `{Resource}Entity` | `PetEntity`, `RoomEntity` | +| **Pages** | `{Page}Page` | `RoomsPage`, `AdminPage` | +| **Enums** | `{Type}` or `{Type}Enum` | `RoomType`, `PetStatus` | + +## Workflows + +### Creating a New REST API Test + +**Step 1: Create or extend feature file** + +Location: `src/test/resources/features/{system}/{domain}.feature` + +```gherkin +@api @{domain} +Feature: {Domain} Management + + {Description of what the feature covers.} + + Background: {Descriptive background title} + # Common setup if needed + + @blocker @sanity + Scenario: User can create a {resource} + Given user creates a {resource} with name "Test {Resource}" + When user retrieves the {resource} + Then the {resource} should have status "ACTIVE" + + @critical + Scenario Outline: User can create multiple {resource}s + Given user creates a {resource} with name "" + Then the {resource} should be created successfully + + Examples: + | name | + | Resource1 | + | Resource2 | +``` + +**Note**: Background sections should always have descriptive text (e.g., `Background: User has a pet in the store`). + +**Step 2: Create step definition** + +Location: `src/test/java/com/levi9/functionaltests/stepdefs/{system}/{Domain}Stepdef.java` + +Match the annotation style of the system: +- **Pet Store**: Regex patterns with `^...$`, double-quoted strings in Gherkin +- **Restful Booker**: Cucumber Expressions with `{string}`, `{int}`, single-quoted strings in Gherkin + +**Step 3: Create service** + +Location: `src/main/java/com/levi9/functionaltests/rest/service/{system}/{Domain}Service.java` + +Follow the constructor injection pattern. Throw `FunctionalTestsException` on bad status codes, NOT AssertJ assertions. Use the appropriate REST client for the system. + +**Step 4: Create DSOs (if Pet Store) or use generated models (if Restful Booker)** + +For Pet Store: create DSOs in `src/main/java/com/levi9/functionaltests/rest/data/petstore/` +For Restful Booker: check if OpenAPI-generated models exist in `restfulbooker.model.*`. If not, either add to the OpenAPI spec and regenerate, or create a hand-crafted DSO in `src/main/java/com/levi9/functionaltests/rest/data/restfulbooker/`. + +**Tip**: If the API has an OpenAPI spec, use the helper script to generate DSO templates: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/{system}/{domain}-open-api.json \ + {SchemaName} \ + com.levi9.functionaltests.rest.data.{system} +``` + +**Step 5: Create entity and add to Storage** + +Location: `src/main/java/com/levi9/functionaltests/storage/domain/{system}/{Resource}Entity.java` + +Then update `Storage.java` with the new entity list and `getLast{Resource}()` convenience method. + +**Step 6: Verify step definitions are properly linked (dry-run)** + +```bash +mvn test -Dtest=DryRunRunnerIT +``` + +This validates that all Gherkin steps have matching step definitions. Fix any "Undefined step" errors. + +**Step 7: Run the test** + +```bash +mvn clean verify -Dtags='@{domain}' +``` + +If the test fails, investigate the failure, fix the issue, and run again. + +### Creating a New UI Test + +**Step 1: Create feature file** + +Location: `src/test/resources/features/{system}/{subdirectory}/{domain}.feature` + +```gherkin +@ui @{domain} +Feature: {Domain} UI + + Background: {Descriptive background title} + Given user is on the {Page} Page + + @blocker @sanity + Scenario: User can perform action via UI + When user fills form with 'Test Data' + And user clicks submit + Then success message should be displayed +``` + +**Note**: UI feature files use Cucumber Expressions with **single quotes** for `{string}` values. Background sections should have descriptive text. + +**Step 2: Create page object** + +Location: `src/main/java/com/levi9/functionaltests/ui/pages/{system}/{Page}Page.java` + +Follow the `BasePage` pattern. Every page needs `isLoaded()` and `load()`. Use `@Nullable` on parameters for negative/validation tests. + +**Step 3: Create step definitions** + +Location: `src/test/java/com/levi9/functionaltests/stepdefs/{system}/{Domain}Stepdef.java` + +**Important**: Always inject `BannerPage` and call `bannerPage.closeBanner()` after loading any Restful Booker page — the welcome banner blocks interactions. + +```java +@Given("User is on the {Page} Page") +public void userIsOnPage() { + page.load(); + bannerPage.closeBanner(); + assertThat(page.isLoaded()).as("User is not on the {Page} Page!").isTrue(); + log.info("User is on {Page} Page"); +} +``` + +**Step 4: Verify step definitions (dry-run)** + +```bash +mvn test -Dtest=DryRunRunnerIT +``` + +**Step 5: Run the test** + +```bash +mvn clean verify -Dtags='@{domain} and @ui' +``` + +### Debugging Failing Tests + +When a test fails, follow this systematic approach: + +1. **Read the error message carefully** — Look at the stack trace, assertion failure message, or Selenium error. + +2. **Check logs** — Examine `logs/functional-tests-{user}.{date}.log` for detailed logging. + +3. **Review screenshots** — For UI tests, check `target/test-artifacts/{ScenarioName}/screenshots/` for failure screenshots. + +4. **Run dry-run** — Verify step definitions are properly linked: + ```bash + mvn test -Dtest=DryRunRunnerIT + ``` + +5. **Common issues**: + - **Undefined steps**: Step definition regex/expression doesn't match Gherkin + - **NullPointerException**: Storage entity not created or autowiring failed + - **Assertion failure**: Response/UI state doesn't match expectation + - **Timeout**: Element not found (UI) or slow response (API) + - **404/500 errors**: Wrong endpoint path or server issue + - **Banner blocking**: Forgot `bannerPage.closeBanner()` after loading page + +6. **Fix and verify** — After fixing, run the specific test again: + ```bash + mvn clean verify -Dtags='@{specific-tag}' + ``` + +### Code Review Checklist + +When reviewing test code, verify: + +**Feature Files**: +- [ ] Scenarios have appropriate tags (`@api`/`@ui`, severity `@blocker/@critical/@normal/@minor`, domain) +- [ ] Background is used for common setup with descriptive text +- [ ] Scenario Outlines are used for data-driven tests +- [ ] Gherkin is readable and follows Given/When/Then structure +- [ ] No implementation details in Gherkin (keep it business-focused) +- [ ] String quoting matches step style: single quotes for Cucumber Expressions, double for regex + +**Step Definitions**: +- [ ] Uses `@Slf4j` and logs each step +- [ ] Annotation style matches the system (Cucumber Expressions for Restful Booker, regex for Pet Store) +- [ ] Delegates to services (API) or pages (UI) — stepdefs should be thin orchestrators +- [ ] Assertions use AssertJ with `.as()` descriptions; `assertSoftly` for multi-field checks +- [ ] Parameters are properly captured and typed + +**Services** (API): +- [ ] Annotated with `@Component` and `@Scope("cucumber-glue")` +- [ ] Uses **constructor injection** with `@Autowired` on constructor (not field injection) +- [ ] Dependencies stored in `private final` fields +- [ ] **Throws `FunctionalTestsException`** on bad status codes (not AssertJ assertions) +- [ ] Uses `HttpStatus.SC_*` constants from `org.apache.http.HttpStatus` +- [ ] Updates Storage after operations +- [ ] Logs important actions and results +- [ ] `public static final String REST_PATH` constant for endpoint paths + +**Page Objects** (UI): +- [ ] Extends `BasePage` with self-type +- [ ] Constructor takes `BaseDriver`, calls `super(baseDriver)` +- [ ] Has `load()` and `isLoaded()` methods +- [ ] Locators are `private final By` fields with descriptive names +- [ ] Uses wait-based methods (`waitAndClick`, `waitAndSendKeys`, etc.) +- [ ] Action methods return `void` (project convention — not fluent API) +- [ ] Verification methods return `boolean` or specific types +- [ ] Uses `@Nullable` (`javax.annotation.Nullable`) on parameters for negative/validation test methods +- [ ] Assertions done in step definitions, not page objects + +**DSOs**: +- [ ] Uses Lombok annotations correctly +- [ ] `@JsonProperty` for non-standard field names +- [ ] `@Builder(toBuilder = true)` for immutability + +**Entities**: +- [ ] Uses enums for status/type fields (not Strings) +- [ ] Includes `deleted` flag if applicable (Pet Store entities) +- [ ] Uses `@Builder.Default` for defaults +- [ ] Consider constructor from generated model (Restful Booker entities) + +**General**: +- [ ] Consistent naming conventions +- [ ] No code duplication +- [ ] Proper package organization +- [ ] No hardcoded values (use properties or constants) +- [ ] Error handling where appropriate + +## Running Tests + +### Basic Execution + +```bash +# Run all sanity tests (default) +mvn clean verify + +# Run specific tags +mvn clean verify -Dtags='@api' +mvn clean verify -Dtags='@ui' +mvn clean verify -Dtags='@pet' + +# Complex tag expressions +mvn clean verify -Dtags='(@ui or @api) and (not @skip and not @bug)' +mvn clean verify -Dtags='@management and not @room-management' +``` + +### Parallelization + +```bash +# Run with 5 scenarios in parallel +mvn clean verify -DparallelCount=5 + +# Run with 10 scenarios in parallel +mvn clean verify -DparallelCount=10 +``` + +### Environment Selection + +```bash +# Use development environment (default) +mvn clean verify -Denv=development + +# Use local environment +mvn clean verify -Denv=local +``` + +### Browser Configuration (UI tests) + +```bash +# Run with Chrome (default) +mvn clean verify -Dbrowser=chrome + +# Run with Firefox +mvn clean verify -Dbrowser=firefox + +# Run in headless mode +mvn clean verify -Dheadless + +# Run on Selenium Grid +mvn clean verify -Dremote=true -DremoteUrl=http://localhost:4444/wd/hub +``` + +### Combined Example + +```bash +mvn clean verify \ + -Dtags='(@api or @ui) and @sanity and not @bug' \ + -DparallelCount=5 \ + -Denv=development \ + -Dbrowser=chrome \ + -Dheadless \ + -Dremote=false +``` + +### Viewing Reports + +After test execution, view reports: +- **Cluecumber**: `target/cucumber/cluecumber-report/index.html` +- **Cucumber HTML**: `target/cucumber/cucumber-html-reports/overview-features.html` +- **Allure**: Run `allure serve target/cucumber/allure-results` + +## OpenAPI Model Generation + +This project uses OpenAPI Generator to create model classes from OpenAPI specifications. + +### OpenAPI Helper Script + +The skill includes a Python script (`scripts/openapi_helper.py`) to help work with OpenAPI specifications during test development. + +**List all available endpoints:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + src/main/resources/openapi/restfulbooker/room-open-api.json +``` +This displays a table of all API endpoints with their methods, paths, operation IDs, and tags. + +**Get endpoint details:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + /room/{id} +``` +This shows detailed information about a specific endpoint: parameters, request body schemas, response codes, and descriptions. + +**Generate DSO template:** +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + Room \ + com.levi9.functionaltests.rest.data.restfulbooker +``` +This generates a complete Java DSO class with Lombok annotations and correct types. + +### Adding New OpenAPI Specs + +1. **Place spec file** in `src/main/resources/openapi/{system}/{domain}-open-api.json` + +2. **Add execution** in `pom.xml`: +```xml + + generate-{domain}-client + + generate + + + ${project.basedir}/src/main/resources/openapi/{system}/{domain}-open-api.json + java + ${project.build.directory}/generated-sources/generated + {system}.model.{domain} + false + true + + . + rest-assured + true + @lombok.experimental.SuperBuilder + + + +``` + +3. **Generate models**: +```bash +mvn clean compile +``` + +4. **Mark as sources** in IntelliJ: +- Right-click `target/generated-sources/generated` +- Select "Mark Directory as" → "Generated Sources Root" + +## Jira & GitHub Ticket Integration + +The skill includes a script to fetch ticket details from **Jira** or **GitHub Issues** and extract feature requirements, acceptance criteria, and linked issues — so you can generate tests directly from tickets. + +### Setup + +Copy the `.env.example` file and fill in your credentials: + +```bash +cp .github/skills/functional-tests-skill/scripts/.env.example \ + .github/skills/functional-tests-skill/scripts/.env +``` + +Or set environment variables directly — see `.env.example` for the full list. + +**Jira:** +```bash +export JIRA_BASE_URL=https://yourcompany.atlassian.net +export JIRA_USER_EMAIL=your-email@company.com +export JIRA_API_TOKEN=your-api-token +# OR for on-prem: export JIRA_PAT=your-personal-access-token +``` + +**GitHub:** +```bash +export GITHUB_TOKEN=ghp_your-personal-access-token +``` + +### Usage + +The script auto-detects the source from the ticket reference format: +- **Jira**: `PROJ-123` +- **GitHub**: `owner/repo#123` + +**Fetch ticket details** (description, acceptance criteria, subtasks, linked issues): +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch owner/repo#42 +``` + +**Fetch with all child/subtask details expanded** (Jira only): +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 --include-children +``` + +**Output as JSON** (useful for piping into other tools): +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 --format json +``` + +**Generate a Gherkin feature skeleton** from the ticket's acceptance criteria: +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py generate-gherkin PROJ-123 +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py generate-gherkin owner/repo#42 +``` + +### Workflow: From Ticket to Tests + +When the user provides a ticket reference (e.g., "write tests for PROJ-123" or "write tests for owner/repo#42"): + +1. **Check if credentials are configured** — Run the fetch script. If it fails with an authentication or missing env var error, **do NOT ask the user to provide tokens or credentials to you**. Instead, give them clear setup instructions: + + **If Python is not installed** (script fails with "command not found" or similar): + + > The ticket fetcher script requires **Python 3.10+**. It doesn't appear to be installed. Here's how to install it: + > + > **macOS** (using Homebrew): + > ```bash + > brew install python3 + > ``` + > If you don't have Homebrew: https://brew.sh + > + > **Windows**: + > Download from https://www.python.org/downloads/ and run the installer. Make sure to check "Add Python to PATH". + > + > **Linux (Debian/Ubuntu)**: + > ```bash + > sudo apt update && sudo apt install python3 + > ``` + > + > After installing, verify with `python3 --version` and try again. + + **Do NOT install Python for the user** — only provide the instructions above and let them do it. + + **If Jira/GitHub credentials are missing:** + + > For **Jira** tickets, you need to set up credentials before I can fetch ticket data. Here's how: + > + > 1. Copy the example env file: + > ```bash + > cp .github/skills/functional-tests-skill/scripts/.env.example .github/skills/functional-tests-skill/scripts/.env + > ``` + > 2. Edit `.github/skills/functional-tests-skill/scripts/.env` and fill in your Jira credentials: + > ``` + > JIRA_BASE_URL=https://yourcompany.atlassian.net + > JIRA_USER_EMAIL=your-email@company.com + > JIRA_API_TOKEN=your-api-token + > ``` + > Generate an API token at: https://id.atlassian.com/manage-profile/security/api-tokens + > + > Once configured, ask me again and I'll fetch the ticket. + + > For **GitHub** issues (private repos), you need a GitHub token: + > + > 1. Copy the example env file (if not already done): + > ```bash + > cp .github/skills/functional-tests-skill/scripts/.env.example .github/skills/functional-tests-skill/scripts/.env + > ``` + > 2. Edit `.github/skills/functional-tests-skill/scripts/.env` and add: + > ``` + > GITHUB_TOKEN=ghp_your-personal-access-token + > ``` + > Generate a token at: https://github.com/settings/tokens (needs `repo` scope for private repos) + > + > For public repos, no token is needed — the script works without it. + + **Important**: Never ask the user to paste tokens or credentials into the chat. Always direct them to edit the `.env` file themselves. + +2. **Fetch the ticket** using the script to get the full context — summary, description, acceptance criteria, subtasks, and linked issues. +3. **Analyze the output** to understand what feature is being described, what the expected behavior is, and what edge cases exist. +4. **Optionally generate a Gherkin skeleton** as a starting point using `generate-gherkin`. +5. **Follow the standard test creation workflow** (described in the Workflows section) to implement the full test — feature file, step definitions, services, page objects, etc. +6. **Map acceptance criteria to scenarios** — each acceptance criterion should become at least one scenario (happy path), plus rainy-day variants where applicable. + +The script supports Jira Cloud, Jira Server/Data Center (via PAT), and GitHub Issues (public and private repos). For GitHub, it also fetches issue comments and parses task list checkboxes as subtasks. + +## Spring Configuration + +The Spring context is configured in `src/test/java/com/levi9/functionaltests/config/SpringConfig.java`: + +```java +@Configuration +@Scope("cucumber-glue") +@PropertySource("classpath:application-${env:development}.properties") +@ComponentScan({ "com.levi9.functionaltests" }) +public class SpringConfig { + @Bean + public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { + return new PropertySourcesPlaceholderConfigurer(); + } +} +``` + +The `env` system property defaults to `development`. Property files are in `src/test/resources/`: +- `application-development.properties` +- `application-local.properties` +- `application-acceptance.properties` + +## Best Practices Summary + +1. **Always run tests after creation/modification** — Verify they work, fix if they fail. + +2. **Study existing code first** — Find similar tests and match their patterns exactly. + +3. **Use descriptive names** — Make code self-documenting. + +4. **Log everything** — Use `@Slf4j` and log steps, actions, and important data. + +5. **Assertions with descriptions** — Use `.as("description")` in AssertJ assertions. Use `assertSoftly` for multi-field checks. + +6. **Update Storage consistently** — Every create/update operation should call `storage.get{Resource}s().add(entity)`. + +7. **Wait-based UI interactions** — Always use `waitAndClick()`, `waitAndSendKeys()`, etc. for stability. Never use `Thread.sleep()`. + +8. **Page Object conventions** — Every page needs `load()` and `isLoaded()`. Use `void` for action methods, `boolean`/specific types for verification methods. Use `@Nullable` for validation test parameters. + +9. **Close the banner** — Always call `bannerPage.closeBanner()` after loading a Restful Booker page. + +10. **Verify with dry-run** — Always run `mvn test -Dtest=DryRunRunnerIT` to validate step definitions before running full tests. + +11. **Comprehensive coverage** — Happy path + rainy day + edge cases. + +12. **Clean Gherkin** — Business-focused, no implementation details. Use descriptive Background text. + +13. **Match step annotation style** — Use Cucumber Expressions (with single-quoted strings) for Restful Booker, regex (with double-quoted strings) for Pet Store. + +14. **Constructor injection in services** — Use `@Autowired` on constructor with `private final` fields. Throw `FunctionalTestsException` on failures (not AssertJ assertions). + +15. **Minimal effort maintenance** — Write code that's easy to understand and modify. + +## When to Use This Skill + +Use this skill whenever working with tests in this project: + +- Writing new Cucumber feature files +- Creating Gherkin scenarios +- Writing step definitions +- Building REST API tests (services, REST clients, DSOs) +- Building UI tests (page objects, Selenium interactions) +- Debugging failing tests +- Fixing test failures +- Refactoring test code +- Reviewing test code for quality +- Adding test coverage +- Generating test data +- Creating entities and updating Storage +- Adding custom parameter types +- Extending REST clients +- Working with OpenAPI models +- Improving test architecture +- Fetching Jira or GitHub ticket details to generate tests +- Creating tests from Jira/GitHub tickets or acceptance criteria + +Even if the user just mentions "write a test", "add coverage", "this test is failing", "review this test", "write tests for PROJ-123", "write tests for owner/repo#42", or references any ticket key, trigger this skill immediately. + +## Final Note + +The goal is to create tests that are **indistinguishable from existing code** in terms of quality, style, and patterns. Always prioritize consistency, readability, and reusability. When in doubt, look at existing similar tests and follow their exact approach. diff --git a/.github/skills/functional-tests-skill/scripts/.env.example b/.github/skills/functional-tests-skill/scripts/.env.example new file mode 100644 index 0000000..7bb14c7 --- /dev/null +++ b/.github/skills/functional-tests-skill/scripts/.env.example @@ -0,0 +1,16 @@ +# Jira Ticket Fetcher — Environment Variables +# Copy this file to .env and fill in your values. +# Place .env next to the script (scripts/.env) or in the project root. + +# --- Jira Cloud --- +JIRA_BASE_URL=https://yourcompany.atlassian.net +JIRA_USER_EMAIL=your-email@company.com +JIRA_API_TOKEN=your-api-token + +# --- Jira Server / Data Center (use PAT instead of email+token) --- +# JIRA_BASE_URL=https://jira.yourcompany.com +# JIRA_PAT=your-personal-access-token + +# --- GitHub Issues --- +# GITHUB_TOKEN=ghp_your-github-personal-access-token +# Generate at: https://github.com/settings/tokens (needs "repo" scope for private repos) diff --git a/.github/skills/functional-tests-skill/scripts/README.md b/.github/skills/functional-tests-skill/scripts/README.md new file mode 100644 index 0000000..678a65e --- /dev/null +++ b/.github/skills/functional-tests-skill/scripts/README.md @@ -0,0 +1,313 @@ +# Functional Tests Skill — Scripts + +Helper scripts for the `functional-tests-skill`. No external dependencies required — all scripts use Python standard library only (PyYAML optional for YAML OpenAPI specs). + +## Scripts + +| Script | Purpose | +|--------|---------| +| [`openapi_helper.py`](#openapi-helper) | List endpoints, view details, generate Java DSOs from OpenAPI specs | +| [`jira_ticket_fetcher.py`](#jira--github-ticket-fetcher) | Fetch ticket details from Jira or GitHub Issues for test generation | + +## Requirements + +- Python 3.10+ +- PyYAML (optional, only for YAML OpenAPI specs): `pip install pyyaml` + +--- + +## OpenAPI Helper + +This Python script helps developers work with OpenAPI specifications during test development. + +### Capabilities + +1. **List Endpoints** — Display all API endpoints from an OpenAPI spec +2. **Endpoint Details** — Show detailed information about a specific endpoint +3. **Generate DSO** — Generate Java DSO class templates from OpenAPI schemas + +✅ Supports local files and remote URLs +✅ Handles both JSON and YAML OpenAPI specifications +✅ No external dependencies + +### Usage + +#### List All Endpoints + +Display a table of all available API endpoints: + +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + src/main/resources/openapi/restfulbooker/room-open-api.json +``` + +**Examples:** + +Local JSON file: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + src/main/resources/openapi/restfulbooker/room-open-api.json +``` + +Remote URL: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + https://petstore.swagger.io/v2/swagger.json +``` + +Local YAML file: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py list-endpoints \ + specs/api-spec.yaml +``` + +**Output:** +``` +==================================================================================================== +Method Path Operation ID Tags +==================================================================================================== +GET /{id} getRoom room-controller +PUT /{id} updateRoom room-controller +DELETE /{id} deleteRoom room-controller +GET / getRooms room-controller +POST / createRoom room-controller +==================================================================================================== + +Total endpoints: 5 +``` + +#### Get Endpoint Details + +Show detailed information about a specific endpoint: + +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + /room/{id} +``` + +**Examples:** + +Local file: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + /room/{id} +``` + +Remote URL: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py endpoint-details \ + https://petstore.swagger.io/v2/swagger.json \ + /pet/{petId} +``` + +#### Generate DSO Class Template + +Generate a complete Java DSO class from an OpenAPI schema: + +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + Room \ + com.levi9.functionaltests.rest.data.restfulbooker +``` + +**Examples:** + +Local file: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + src/main/resources/openapi/restfulbooker/room-open-api.json \ + Room \ + com.levi9.functionaltests.rest.data.restfulbooker +``` + +Remote URL: +```bash +python3 .github/skills/functional-tests-skill/scripts/openapi_helper.py generate-dso \ + https://petstore.swagger.io/v2/swagger.json \ + Pet \ + com.levi9.functionaltests.rest.data.petstore +``` + +**Output:** +```java +package com.levi9.functionaltests.rest.data.restfulbooker; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * Data Service Object for Room + * Generated from OpenAPI specification + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class RoomDSO { + + private Integer roomid; + + @JsonProperty("roomName") + private String roomName; + + private String type; + + // ... other fields +} +``` + +The generated DSO includes: +- ✅ Lombok annotations (`@Getter`, `@Setter`, `@Builder`, `@NoArgsConstructor`, `@AllArgsConstructor`) +- ✅ Correct Java types based on OpenAPI schema +- ✅ `@JsonProperty` annotations for non-standard field names +- ✅ JavaDoc comments from OpenAPI descriptions +- ✅ Proper imports for List, LocalDate, LocalDateTime + +### Type Mapping + +The script automatically maps OpenAPI types to Java types: + +| OpenAPI Type | OpenAPI Format | Java Type | +|--------------|----------------|-----------| +| string | - | String | +| string | date | LocalDate | +| string | date-time | LocalDateTime | +| integer | int32 | Integer | +| integer | int64 | Long | +| number | float | Float | +| number | double | Double | +| boolean | - | Boolean | +| array | - | List | +| object | - | Object or referenced type | + +### Notes + +- Reads OpenAPI 3.0 and Swagger 2.0 specifications +- Supports both JSON and YAML formats (PyYAML required for YAML) +- URLs are fetched with a 30-second timeout +- Default package: `com.levi9.functionaltests.rest.data` +- Generated DSOs follow project conventions (Lombok, builder pattern, etc.) + +--- + +## Jira & GitHub Ticket Fetcher + +Fetches ticket details from **Jira** or **GitHub Issues** and extracts feature requirements, acceptance criteria, subtasks, and linked issues — enabling test generation directly from tickets. + +### Setup + +1. Copy the example environment file and fill in your credentials: + +```bash +cp .github/skills/functional-tests-skill/scripts/.env.example \ + .github/skills/functional-tests-skill/scripts/.env +``` + +2. Fill in the relevant values in `.env`: + +**Jira Cloud:** +``` +JIRA_BASE_URL=https://yourcompany.atlassian.net +JIRA_USER_EMAIL=your-email@company.com +JIRA_API_TOKEN=your-api-token +``` + +**Jira Server / Data Center (PAT):** +``` +JIRA_BASE_URL=https://jira.yourcompany.com +JIRA_PAT=your-personal-access-token +``` + +**GitHub Issues:** +``` +GITHUB_TOKEN=ghp_your-personal-access-token +``` + +> The `.env` file is auto-loaded from next to the script or from the project root. Already-set environment variables take precedence. + +### Usage + +The script auto-detects the source based on the ticket reference format: +- **Jira:** `PROJ-123` +- **GitHub:** `owner/repo#123` + +#### Fetch Ticket Details + +```bash +# Jira +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 + +# GitHub +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch owner/repo#42 +``` + +Output includes: summary, description, acceptance criteria, subtasks, linked issues, and (for GitHub) comments. + +#### Fetch with Child/Subtask Details (Jira only) + +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 --include-children +``` + +#### Output as JSON + +```bash +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py fetch PROJ-123 --format json +``` + +#### Generate Gherkin Skeleton + +Generates a `.feature` file skeleton from the ticket's acceptance criteria: + +```bash +# Jira +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py generate-gherkin PROJ-123 + +# GitHub +python3 .github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py generate-gherkin owner/repo#42 +``` + +### GitHub-Specific Features + +- Fetches issue comments (up to 10) for additional context +- Parses task list checkboxes (`- [ ]` / `- [x]`) as subtasks +- Extracts `## Acceptance Criteria` sections from the issue body +- Works with public repos without a token; private repos require `GITHUB_TOKEN` with `repo` scope + +### Workflow: From Ticket to Tests + +1. **Fetch the ticket** to get full context +2. **Analyze** the description and acceptance criteria +3. **Generate a Gherkin skeleton** (optional starting point) +4. **Implement the full test** — feature file, step definitions, services, page objects, etc. +5. **Map each acceptance criterion** to at least one scenario + +### Notes + +- Uses Jira REST API v3 (Cloud) — works with Jira Server/Data Center via PAT +- `.env` file is searched next to the script, then in the project root +- No external Python dependencies required + +--- + +## File Structure + +``` +scripts/ +├── README.md ← This file +├── .env.example ← Template for environment variables +├── openapi_helper.py ← OpenAPI spec helper +└── jira_ticket_fetcher.py ← Jira & GitHub ticket fetcher +``` diff --git a/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py b/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py new file mode 100644 index 0000000..dd5abb4 --- /dev/null +++ b/.github/skills/functional-tests-skill/scripts/jira_ticket_fetcher.py @@ -0,0 +1,674 @@ +#!/usr/bin/env python3 +""" +Jira & GitHub Ticket Fetcher — Extracts ticket details to help generate test scenarios. + +Connects to a Jira instance or GitHub via REST API, fetches ticket details, and +outputs structured information about the feature, what needs to be done, and how +it should work. + +Usage: + # Fetch a single Jira ticket + python3 jira_ticket_fetcher.py fetch PROJ-123 + + # Fetch a Jira ticket with all linked/child issues + python3 jira_ticket_fetcher.py fetch PROJ-123 --include-children + + # Fetch and output as markdown (default) + python3 jira_ticket_fetcher.py fetch PROJ-123 --format markdown + + # Fetch and output as JSON + python3 jira_ticket_fetcher.py fetch PROJ-123 --format json + + # Fetch and generate a Gherkin skeleton from acceptance criteria + python3 jira_ticket_fetcher.py generate-gherkin PROJ-123 + + # GitHub Issues — fetch an issue + python3 jira_ticket_fetcher.py fetch owner/repo#42 + python3 jira_ticket_fetcher.py fetch owner/repo#42 --format json + + # GitHub Issues — generate Gherkin skeleton + python3 jira_ticket_fetcher.py generate-gherkin owner/repo#42 + +Environment Variables (set before running, or use a .env file — see .env.example): + + Jira: + JIRA_BASE_URL — Base URL (e.g., https://yourcompany.atlassian.net) + JIRA_USER_EMAIL — Email for Jira Cloud authentication + JIRA_API_TOKEN — API token (https://id.atlassian.com/manage-profile/security/api-tokens) + JIRA_PAT — Personal Access Token (alternative to email+token, for on-prem) + + GitHub: + GITHUB_TOKEN — Personal access token (https://github.com/settings/tokens) +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import textwrap +from base64 import b64encode +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +_GITHUB_API_BASE = "https://api.github.com" +_GITHUB_API_VERSION = "2022-11-28" +_JIRA_API_VERSION = "3" +_MAX_GITHUB_COMMENTS = 10 +_MAX_AC_TITLE_LENGTH = 120 +_MIN_AC_TITLE_LENGTH = 10 +_MAX_ERROR_BODY_LENGTH = 500 +_GHERKIN_WRAP_WIDTH = 100 + +# Acceptance criteria custom field IDs (common Jira configurations) +_AC_CUSTOM_FIELDS = ("customfield_10400", "customfield_10300", "customfield_10206") + +# Pre-compiled regex patterns +_GITHUB_REF_PATTERN = re.compile(r"^([\w.-]+)/([\w.-]+)#(\d+)$") +_AC_HEADING_PATTERN = re.compile( + r"(?:^|\n)#{1,4}\s*(?:acceptance\s*criteria|AC)\s*\n(.*?)(?=\n#{1,4}\s|\Z)", + re.IGNORECASE | re.DOTALL, +) +_TASK_LIST_PATTERN = re.compile(r"- \[([ xX])\]\s+(.*)") +_AC_ITEMS_PATTERN = re.compile( + r"(?:^|\n)\s*(?:\d+[.)]\s*|[-*•]\s*)(.*?)(?=\n\s*(?:\d+[.)]\s*|[-*•]\s*)|\Z)", + re.DOTALL, +) + + +# --------------------------------------------------------------------------- +# .env file loader +# --------------------------------------------------------------------------- + +def _load_env_file() -> None: + """Load variables from a .env file if present. + + Searches for .env in this order: + 1. Next to this script (.github/skills/functional-tests-skill/scripts/.env) + 2. In the project root (functional-tests/.env) + + Lines like KEY=VALUE or KEY="VALUE" are parsed. Comments (#) and blank + lines are ignored. Already-set env vars are NOT overwritten. + """ + script_dir = Path(__file__).resolve().parent + project_root = script_dir.parents[3] + + for env_path in (script_dir / ".env", project_root / ".env"): + if env_path.is_file(): + _parse_env_file(env_path) + break + + +def _parse_env_file(env_path: Path) -> None: + """Parse a single .env file and set missing environment variables.""" + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("\"'") + if key and key not in os.environ: + os.environ[key] = value + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + +def _exit_with_error(message: str) -> None: + """Print an error message to stderr and exit.""" + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(1) + + +def _handle_http_error(error: HTTPError, url: str) -> None: + """Handle an HTTP error by printing details and exiting.""" + body = error.read().decode() if error.fp else "" + messages = [f"HTTP {error.code} from {url}"] + + if error.code == 401: + messages.append(" Authentication failed. Check your credentials.") + elif error.code == 404: + messages.append(" Resource not found. Check the ticket key or repository.") + + if body: + try: + parsed_msg = json.loads(body).get("message", body[:_MAX_ERROR_BODY_LENGTH]) + except (json.JSONDecodeError, AttributeError): + parsed_msg = body[:_MAX_ERROR_BODY_LENGTH] + messages.append(f" {parsed_msg}") + + _exit_with_error("\n".join(messages)) + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _http_get(url: str, headers: dict[str, str]) -> dict: + """Perform an HTTP GET request and return parsed JSON.""" + req = Request(url, headers=headers, method="GET") + try: + with urlopen(req) as resp: + return json.loads(resp.read().decode()) + except HTTPError as e: + _handle_http_error(e, url) + except URLError as e: + _exit_with_error(f"Cannot reach {url} — {e.reason}") + return {} # unreachable, satisfies type checker + + +# --------------------------------------------------------------------------- +# Authentication helpers +# --------------------------------------------------------------------------- + +def _get_jira_auth_header() -> str | None: + """Build the Authorization header for Jira based on available env vars.""" + pat = os.environ.get("JIRA_PAT") + if pat: + return f"Bearer {pat}" + + email = os.environ.get("JIRA_USER_EMAIL") + token = os.environ.get("JIRA_API_TOKEN") + if email and token: + creds = b64encode(f"{email}:{token}".encode()).decode() + return f"Basic {creds}" + + return None + + +def _get_jira_base_url() -> str: + """Return the Jira base URL from environment, or exit with an error.""" + url = os.environ.get("JIRA_BASE_URL", "").rstrip("/") + if not url: + _exit_with_error( + "JIRA_BASE_URL environment variable is not set.\n" + " Set it to your Jira instance URL, e.g.:\n" + " export JIRA_BASE_URL=https://yourcompany.atlassian.net" + ) + return url + + +# --------------------------------------------------------------------------- +# Jira API interaction +# --------------------------------------------------------------------------- + +def _jira_api_get(path: str) -> dict: + """Perform a GET request to the Jira REST API.""" + base_url = _get_jira_base_url() + url = f"{base_url}/rest/api/{_JIRA_API_VERSION}/{path.lstrip('/')}" + headers = {"Accept": "application/json", "Content-Type": "application/json"} + auth = _get_jira_auth_header() + if auth: + headers["Authorization"] = auth + return _http_get(url, headers) + + +# --------------------------------------------------------------------------- +# GitHub API interaction +# --------------------------------------------------------------------------- + +def _github_api_get(path: str) -> dict: + """Perform a GET request to the GitHub REST API.""" + url = f"{_GITHUB_API_BASE}/{path.lstrip('/')}" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": _GITHUB_API_VERSION, + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + return _http_get(url, headers) + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + +def _nested_get(data: dict | None, *keys: str) -> str | None: + """Safely traverse nested dicts, returning None if any key is missing.""" + current: dict | None = data + for key in keys: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + +# --------------------------------------------------------------------------- +# Jira ticket parsing +# --------------------------------------------------------------------------- + +def _extract_jira_ticket(data: dict) -> dict: + """Extract the useful fields from a Jira issue JSON response.""" + fields = data.get("fields", {}) + return { + "key": data.get("key"), + "summary": fields.get("summary"), + "type": _nested_get(fields, "issuetype", "name"), + "status": _nested_get(fields, "status", "name"), + "priority": _nested_get(fields, "priority", "name"), + "assignee": _nested_get(fields, "assignee", "displayName"), + "reporter": _nested_get(fields, "reporter", "displayName"), + "description": fields.get("description") or "", + "acceptance_criteria": _find_acceptance_criteria(fields), + "labels": fields.get("labels", []), + "components": [c.get("name") for c in fields.get("components", [])], + "fix_versions": [v.get("name") for v in fields.get("fixVersions", [])], + "subtasks": _extract_jira_subtasks(fields), + "linked_issues": _extract_jira_links(fields), + } + + +def _find_acceptance_criteria(fields: dict) -> str: + """Find acceptance criteria from known Jira custom fields.""" + for field_id in _AC_CUSTOM_FIELDS: + value = fields.get(field_id) + if value: + return value + return "" + + +def _extract_jira_subtasks(fields: dict) -> list[dict]: + """Extract subtask info from Jira fields.""" + return [ + { + "key": sub.get("key"), + "summary": _nested_get(sub, "fields", "summary"), + "status": _nested_get(sub, "fields", "status", "name"), + } + for sub in fields.get("subtasks", []) + ] + + +def _extract_jira_links(fields: dict) -> list[dict]: + """Extract linked issue info from Jira fields.""" + links = [] + for link in fields.get("issuelinks", []): + linked = link.get("outwardIssue") or link.get("inwardIssue") + if linked: + links.append({ + "key": linked.get("key"), + "summary": _nested_get(linked, "fields", "summary"), + "relationship": ( + _nested_get(link, "type", "outward") + or _nested_get(link, "type", "name") + ), + }) + return links + + +# --------------------------------------------------------------------------- +# Jira markup conversion +# --------------------------------------------------------------------------- + +def _strip_jira_markup(text: str | dict) -> str: + """Convert Jira wiki markup or ADF to plain text.""" + if not text: + return "" + if isinstance(text, dict): + return _adf_to_text(text) + text = re.sub(r"\{noformat\}.*?\{noformat\}", "", text, flags=re.DOTALL) + text = re.sub(r"\{code[^}]*\}.*?\{code\}", "", text, flags=re.DOTALL) + text = re.sub(r"\[([^|]*)\|[^\]]*\]", r"\1", text) + text = re.sub(r"[{*_~^+]", "", text) + text = re.sub(r"h[1-6]\.\s*", "", text) + text = re.sub(r"^[#\-*]+\s*", "", text, flags=re.MULTILINE) + return text.strip() + + +def _adf_to_text(node: dict) -> str: + """Recursively extract text from Atlassian Document Format.""" + if node.get("type") == "text": + return node.get("text", "") + return "\n".join(_adf_to_text(child) for child in node.get("content", [])) + + +# --------------------------------------------------------------------------- +# GitHub Issues support +# --------------------------------------------------------------------------- + +def _is_github_ref(ticket: str) -> bool: + """Check if the ticket reference is a GitHub issue (owner/repo#123).""" + return bool(_GITHUB_REF_PATTERN.match(ticket)) + + +def _parse_github_ref(ticket: str) -> tuple[str, str, int]: + """Parse 'owner/repo#123' into (owner, repo, issue_number).""" + match = _GITHUB_REF_PATTERN.match(ticket) + if not match: + _exit_with_error( + f"Invalid GitHub issue reference: {ticket}\n" + " Expected format: owner/repo#123" + ) + return match.group(1), match.group(2), int(match.group(3)) + + +def _extract_github_issue(owner: str, repo: str, issue_number: int) -> dict: + """Fetch and extract a GitHub issue into the standard ticket dict.""" + data = _github_api_get(f"repos/{owner}/{repo}/issues/{issue_number}") + body = data.get("body") or "" + + return { + "key": f"{owner}/{repo}#{issue_number}", + "summary": data.get("title", ""), + "type": "Pull Request" if data.get("pull_request") else "Issue", + "status": data.get("state", "open").capitalize(), + "priority": None, + "assignee": _nested_get(data, "assignee", "login"), + "reporter": _nested_get(data, "user", "login"), + "description": body, + "acceptance_criteria": _extract_ac_from_markdown(body), + "labels": [label.get("name") for label in data.get("labels", [])], + "components": [], + "fix_versions": [m.get("title") for m in [data.get("milestone")] if m], + "subtasks": _extract_task_list(body), + "linked_issues": [], + "_comments": _fetch_github_comments(owner, repo, issue_number, data), + } + + +def _fetch_github_comments( + owner: str, repo: str, issue_number: int, issue_data: dict +) -> list[dict]: + """Fetch issue comments if any exist.""" + if issue_data.get("comments", 0) == 0: + return [] + comments_data = _github_api_get( + f"repos/{owner}/{repo}/issues/{issue_number}/comments" + ) + return [ + { + "author": _nested_get(c, "user", "login"), + "body": c.get("body", ""), + } + for c in comments_data[:_MAX_GITHUB_COMMENTS] + ] + + +def _extract_ac_from_markdown(body: str) -> str: + """Try to extract an 'Acceptance Criteria' section from a markdown body.""" + match = _AC_HEADING_PATTERN.search(body) + return match.group(1).strip() if match else "" + + +def _extract_task_list(body: str) -> list[dict]: + """Extract GitHub task list items (- [ ] / - [x]) as subtasks.""" + return [ + { + "key": None, + "summary": text.strip(), + "status": "Done" if checked.lower() == "x" else "To Do", + } + for checked, text in _TASK_LIST_PATTERN.findall(body) + ] + + +# --------------------------------------------------------------------------- +# Formatters +# --------------------------------------------------------------------------- + +def _format_markdown(ticket: dict, children: list[dict] | None = None) -> str: + """Format ticket info as readable markdown.""" + lines: list[str] = [] + _append_header(lines, ticket) + _append_section(lines, "Description", _strip_jira_markup(ticket["description"])) + _append_section(lines, "Acceptance Criteria", _strip_jira_markup(ticket["acceptance_criteria"])) + _append_subtasks(lines, ticket.get("subtasks", [])) + _append_linked_issues(lines, ticket.get("linked_issues", [])) + _append_children(lines, children) + _append_comments(lines, ticket.get("_comments", [])) + return "\n".join(lines) + + +def _append_header(lines: list[str], ticket: dict) -> None: + """Append ticket header metadata.""" + lines.append(f"# {ticket['key']}: {ticket['summary']}") + lines.append("") + lines.append(f"**Type:** {ticket['type']} ") + lines.append(f"**Status:** {ticket['status']} ") + lines.append(f"**Priority:** {ticket['priority']} ") + if ticket["assignee"]: + lines.append(f"**Assignee:** {ticket['assignee']} ") + if ticket.get("labels"): + lines.append(f"**Labels:** {', '.join(ticket['labels'])} ") + if ticket.get("components"): + lines.append(f"**Components:** {', '.join(ticket['components'])} ") + lines.append("") + + +def _append_section(lines: list[str], heading: str, content: str) -> None: + """Append a markdown section if content is non-empty.""" + if content: + lines.extend([f"## {heading}", "", content, ""]) + + +def _append_subtasks(lines: list[str], subtasks: list[dict]) -> None: + """Append subtasks section if present.""" + if not subtasks: + return + lines.extend(["## Subtasks", ""]) + for st in subtasks: + lines.append(f"- **{st['key']}** — {st['summary']} _{st['status']}_") + lines.append("") + + +def _append_linked_issues(lines: list[str], linked_issues: list[dict]) -> None: + """Append linked issues section if present.""" + if not linked_issues: + return + lines.extend(["## Linked Issues", ""]) + for li in linked_issues: + lines.append(f"- {li['relationship']}: **{li['key']}** — {li['summary']}") + lines.append("") + + +def _append_children(lines: list[str], children: list[dict] | None) -> None: + """Append expanded child ticket details if present.""" + if not children: + return + lines.extend(["## Child Ticket Details", ""]) + for child in children: + lines.append(f"### {child['key']}: {child['summary']}") + child_desc = _strip_jira_markup(child["description"]) + if child_desc: + lines.append(child_desc) + child_ac = _strip_jira_markup(child["acceptance_criteria"]) + if child_ac: + lines.extend(["", "**Acceptance Criteria:**", child_ac]) + lines.append("") + + +def _append_comments(lines: list[str], comments: list[dict]) -> None: + """Append GitHub comments section if present.""" + if not comments: + return + lines.extend(["## Comments", ""]) + for c in comments: + lines.append(f"**{c['author']}:**") + lines.append(c["body"].strip()) + lines.append("") + + +def _format_json(ticket: dict, children: list[dict] | None = None) -> str: + """Format as JSON.""" + output = dict(ticket) + if children: + output["children_details"] = children + return json.dumps(output, indent=2, ensure_ascii=False) + + +# --------------------------------------------------------------------------- +# Gherkin generator +# --------------------------------------------------------------------------- + +def _generate_gherkin_skeleton(ticket: dict) -> str: + """Generate a rough Gherkin feature skeleton from ticket details.""" + summary = ticket["summary"] + desc = _strip_jira_markup(ticket["description"]) + ac = _strip_jira_markup(ticket["acceptance_criteria"]) + tag = _determine_test_tag(ticket) + + lines = [ + f"# Auto-generated from {ticket['key']}", + "# Review and refine before use", + "", + f"@{tag}", + f"Feature: {summary}", + "", + ] + + if desc: + for line in textwrap.wrap(desc, width=_GHERKIN_WRAP_WIDTH): + lines.append(f" {line}") + lines.append("") + + scenarios = _parse_ac_into_scenarios(ac or desc) + if scenarios: + for scenario in scenarios: + lines.extend([ + " @normal", + f" Scenario: {scenario['title']}", + " # TODO: Implement steps", + " Given ", + " When ", + " Then ", + "", + ]) + else: + lines.extend([ + " @normal", + f" Scenario: {summary}", + " # TODO: Derive steps from description and acceptance criteria", + " Given ", + " When ", + " Then ", + "", + ]) + + return "\n".join(lines) + + +def _determine_test_tag(ticket: dict) -> str: + """Determine the primary test tag based on ticket type.""" + ticket_type = (ticket.get("type") or "").lower() + if "ui" in ticket_type or "frontend" in ticket_type: + return "ui" + return "api" + + +def _parse_ac_into_scenarios(text: str) -> list[dict]: + """Try to extract individual acceptance criteria as scenario titles.""" + if not text: + return [] + scenarios = [] + for item in _AC_ITEMS_PATTERN.findall(text): + title = item.strip().split("\n")[0].strip() + if len(title) > _MIN_AC_TITLE_LENGTH: + title = title[:_MAX_AC_TITLE_LENGTH].rstrip(".") + scenarios.append({"title": title}) + return scenarios + + +# --------------------------------------------------------------------------- +# Ticket resolver — routes to Jira or GitHub +# --------------------------------------------------------------------------- + +def _fetch_ticket(ticket_ref: str) -> dict: + """Fetch a ticket from Jira or GitHub based on the reference format.""" + if _is_github_ref(ticket_ref): + owner, repo, number = _parse_github_ref(ticket_ref) + return _extract_github_issue(owner, repo, number) + data = _jira_api_get(f"issue/{ticket_ref}?expand=renderedFields") + return _extract_jira_ticket(data) + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_fetch(args: argparse.Namespace) -> None: + """Fetch and display a ticket.""" + ticket = _fetch_ticket(args.ticket) + + children: list[dict] = [] + if args.include_children and not _is_github_ref(args.ticket): + for sub in ticket.get("subtasks", []): + child_data = _jira_api_get(f"issue/{sub['key']}") + children.append(_extract_jira_ticket(child_data)) + + if args.format == "json": + print(_format_json(ticket, children)) + else: + print(_format_markdown(ticket, children)) + + +def cmd_generate_gherkin(args: argparse.Namespace) -> None: + """Fetch a ticket and generate a Gherkin skeleton.""" + ticket = _fetch_ticket(args.ticket) + print(_generate_gherkin_skeleton(ticket)) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + """Entry point for the CLI.""" + parser = argparse.ArgumentParser( + description="Fetch Jira or GitHub ticket details for test generation.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + Jira env vars: + JIRA_BASE_URL https://yourcompany.atlassian.net + JIRA_USER_EMAIL your-email@company.com + JIRA_API_TOKEN your-api-token + OR: + JIRA_PAT personal-access-token + + GitHub env vars: + GITHUB_TOKEN ghp_your-personal-access-token + + See .env.example for a template. + """), + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_fetch = sub.add_parser("fetch", help="Fetch ticket details") + p_fetch.add_argument( + "ticket", help="Ticket key: PROJ-123 (Jira) or owner/repo#123 (GitHub)" + ) + p_fetch.add_argument( + "--format", choices=["markdown", "json"], default="markdown" + ) + p_fetch.add_argument( + "--include-children", + action="store_true", + help="Also fetch subtask/child details", + ) + p_fetch.set_defaults(func=cmd_fetch) + + p_gherkin = sub.add_parser( + "generate-gherkin", help="Generate Gherkin skeleton from ticket" + ) + p_gherkin.add_argument( + "ticket", help="Ticket key: PROJ-123 (Jira) or owner/repo#123 (GitHub)" + ) + p_gherkin.set_defaults(func=cmd_generate_gherkin) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + _load_env_file() + main() + diff --git a/.github/skills/functional-tests-skill/scripts/openapi_helper.py b/.github/skills/functional-tests-skill/scripts/openapi_helper.py new file mode 100755 index 0000000..90ceb91 --- /dev/null +++ b/.github/skills/functional-tests-skill/scripts/openapi_helper.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +""" +OpenAPI Helper Script for Functional Tests + +This script reads OpenAPI specifications and provides information to help with test implementation: +- List all available API endpoints +- Show endpoint details (methods, parameters, schemas) +- Generate DSO class templates from OpenAPI models + +Supports: +- Local JSON files +- Local YAML files +- Remote URLs (JSON/YAML) + +Usage: + python openapi_helper.py list-endpoints + python openapi_helper.py endpoint-details + python openapi_helper.py generate-dso + +Examples: + python openapi_helper.py list-endpoints src/main/resources/openapi/room-open-api.json + python openapi_helper.py list-endpoints https://petstore.swagger.io/v2/swagger.json + python openapi_helper.py generate-dso https://petstore.swagger.io/v2/swagger.json Pet +""" + +import json +import sys +from pathlib import Path +from typing import Dict, List, Any, Optional +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + + +def load_openapi_spec(spec_path: str) -> Dict[str, Any]: + """Load OpenAPI specification from JSON/YAML file or URL.""" + + # Check if it's a URL + if spec_path.startswith('http://') or spec_path.startswith('https://'): + try: + print(f"Fetching OpenAPI spec from URL: {spec_path}") + req = Request(spec_path, headers={'User-Agent': 'OpenAPI-Helper/1.0'}) + with urlopen(req, timeout=30) as response: + content = response.read().decode('utf-8') + + # Try to parse as JSON first + try: + return json.loads(content) + except json.JSONDecodeError: + # Try YAML if JSON fails + return _parse_yaml(content, spec_path) + except HTTPError as e: + print(f"HTTP Error {e.code}: {e.reason}") + print(f"Failed to fetch from URL: {spec_path}") + sys.exit(1) + except URLError as e: + print(f"URL Error: {e.reason}") + print(f"Failed to fetch from URL: {spec_path}") + sys.exit(1) + except Exception as e: + print(f"Error fetching URL: {e}") + sys.exit(1) + + # Local file + spec_file = Path(spec_path) + if not spec_file.exists(): + print(f"Error: File not found: {spec_path}") + sys.exit(1) + + with open(spec_file, 'r') as f: + content = f.read() + + # Determine format by extension or content + if spec_path.endswith('.json'): + return json.loads(content) + elif spec_path.endswith('.yaml') or spec_path.endswith('.yml'): + return _parse_yaml(content, spec_path) + else: + # Try JSON first, then YAML + try: + return json.loads(content) + except json.JSONDecodeError: + return _parse_yaml(content, spec_path) + + +def _parse_yaml(content: str, source: str) -> Dict[str, Any]: + """Parse YAML content, with fallback error if PyYAML not available.""" + try: + import yaml # type: ignore # PyYAML is optional dependency + return yaml.safe_load(content) + except ImportError: + print("Error: PyYAML is not installed.") + print("To parse YAML files, install it with: pip install pyyaml") + print(f"Alternatively, convert the spec to JSON format.") + sys.exit(1) + except Exception as e: + print(f"Error parsing YAML from {source}: {e}") + sys.exit(1) + + +def list_endpoints(spec: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract all endpoints from OpenAPI spec.""" + endpoints = [] + paths = spec.get('paths', {}) + + for path, methods in paths.items(): + for method, details in methods.items(): + if method.upper() in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']: + endpoints.append({ + 'path': path, + 'method': method.upper(), + 'operationId': details.get('operationId', ''), + 'summary': details.get('summary', ''), + 'tags': details.get('tags', []) + }) + + return endpoints + + +def print_endpoints_table(endpoints: List[Dict[str, Any]]): + """Print endpoints in a formatted table.""" + print("\n" + "="*100) + print(f"{'Method':<8} {'Path':<40} {'Operation ID':<30} {'Tags':<20}") + print("="*100) + + for endpoint in endpoints: + tags = ', '.join(endpoint['tags']) if endpoint['tags'] else 'N/A' + print(f"{endpoint['method']:<8} {endpoint['path']:<40} {endpoint['operationId']:<30} {tags:<20}") + + print("="*100) + print(f"\nTotal endpoints: {len(endpoints)}\n") + + +def get_endpoint_details(spec: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]: + """Get detailed information about a specific endpoint.""" + paths = spec.get('paths', {}) + + if path not in paths: + return None + + endpoint_data = paths[path] + details = {'path': path, 'methods': {}} + + for method, info in endpoint_data.items(): + if method.upper() in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']: + method_details = { + 'operationId': info.get('operationId', ''), + 'summary': info.get('summary', ''), + 'description': info.get('description', ''), + 'parameters': info.get('parameters', []), + 'requestBody': info.get('requestBody', {}), + 'responses': info.get('responses', {}) + } + details['methods'][method.upper()] = method_details + + return details + + +def print_endpoint_details(details: Dict[str, Any]): + """Print detailed endpoint information.""" + print(f"\n{'='*80}") + print(f"Endpoint: {details['path']}") + print(f"{'='*80}\n") + + for method, info in details['methods'].items(): + print(f"{method} - {info['operationId']}") + print(f"Summary: {info['summary']}") + + if info['description']: + print(f"Description: {info['description']}") + + # Parameters + if info['parameters']: + print("\nParameters:") + for param in info['parameters']: + required = " (required)" if param.get('required', False) else "" + print(f" - {param['name']} ({param['in']}){required}: {param.get('description', '')}") + + # Request Body + if info['requestBody']: + print("\nRequest Body:") + content = info['requestBody'].get('content', {}) + for content_type, schema_info in content.items(): + print(f" Content-Type: {content_type}") + if 'schema' in schema_info: + print(f" Schema: {schema_info['schema']}") + + # Responses + if info['responses']: + print("\nResponses:") + for status_code, response_info in info['responses'].items(): + description = response_info.get('description', '') + print(f" {status_code}: {description}") + + print("\n" + "-"*80 + "\n") + + +def get_schema_definition(spec: Dict[str, Any], schema_name: str) -> Optional[Dict[str, Any]]: + """Get schema definition from OpenAPI spec (supports both OpenAPI 3.0 and Swagger 2.0).""" + # Try OpenAPI 3.0 format (components/schemas) + components = spec.get('components', {}) + schemas = components.get('schemas', {}) + + if schema_name in schemas: + return schemas.get(schema_name) + + # Try Swagger 2.0 format (definitions) + definitions = spec.get('definitions', {}) + if schema_name in definitions: + return definitions.get(schema_name) + + return None + + +def java_type_mapping(openapi_type: str, format_type: Optional[str] = None) -> str: + """Map OpenAPI types to Java types.""" + type_map = { + 'string': 'String', + 'integer': 'Integer', + 'number': 'Double', + 'boolean': 'Boolean', + 'array': 'List', + 'object': 'Object' + } + + # Handle format-specific types + if format_type: + format_map = { + 'int32': 'Integer', + 'int64': 'Long', + 'float': 'Float', + 'double': 'Double', + 'date': 'LocalDate', + 'date-time': 'LocalDateTime' + } + return format_map.get(format_type, type_map.get(openapi_type, 'Object')) + + return type_map.get(openapi_type, 'Object') + + +def generate_dso_template(spec: Dict[str, Any], schema_name: str, package_name: str = "com.levi9.functionaltests.rest.data") -> str: + """Generate Java DSO class template from OpenAPI schema.""" + schema = get_schema_definition(spec, schema_name) + + if not schema: + # List available schemas + available_schemas = [] + + # Check OpenAPI 3.0 format + components = spec.get('components', {}) + schemas = components.get('schemas', {}) + available_schemas.extend(schemas.keys()) + + # Check Swagger 2.0 format + definitions = spec.get('definitions', {}) + available_schemas.extend(definitions.keys()) + + error_msg = f"Schema '{schema_name}' not found in OpenAPI spec." + if available_schemas: + error_msg += f"\n\nAvailable schemas: {', '.join(sorted(set(available_schemas)))}" + return error_msg + + properties = schema.get('properties', {}) + required_fields = schema.get('required', []) + + # Start building the class + lines = [ + "package " + package_name + ";", + "", + "import com.fasterxml.jackson.annotation.JsonProperty;", + "import lombok.AllArgsConstructor;", + "import lombok.Builder;", + "import lombok.Getter;", + "import lombok.NoArgsConstructor;", + "import lombok.Setter;", + "", + "import java.util.List;", + "import java.time.LocalDate;", + "import java.time.LocalDateTime;", + "", + "/**", + f" * Data Service Object for {schema_name}", + " * Generated from OpenAPI specification", + " */", + "@Getter", + "@Setter", + "@NoArgsConstructor", + "@AllArgsConstructor", + "@Builder(toBuilder = true)", + f"public class {schema_name}DSO {{", + "" + ] + + # Add fields + for prop_name, prop_info in properties.items(): + prop_type = prop_info.get('type', 'object') + prop_format = prop_info.get('format') + prop_description = prop_info.get('description', '') + + java_type = java_type_mapping(prop_type, prop_format) + + # Handle arrays + if prop_type == 'array': + items = prop_info.get('items', {}) + item_type = items.get('type', 'Object') + item_ref = items.get('$ref', '') + + if item_ref: + # Extract schema name from $ref + item_type = item_ref.split('/')[-1] + else: + item_type = java_type_mapping(item_type, items.get('format')) + + java_type = f"List<{item_type}>" + + # Handle object references + if '$ref' in prop_info: + java_type = prop_info['$ref'].split('/')[-1] + + # Add JavaDoc if description exists + if prop_description: + lines.append(f" /**") + lines.append(f" * {prop_description}") + lines.append(f" */") + + # Add @JsonProperty if field name differs from Java conventions + if '_' in prop_name or prop_name != prop_name.lower(): + lines.append(f' @JsonProperty("{prop_name}")') + + # Convert snake_case to camelCase for Java field name + java_field_name = ''.join(word.capitalize() if i > 0 else word + for i, word in enumerate(prop_name.split('_'))) + + lines.append(f" private {java_type} {java_field_name};") + lines.append("") + + lines.append("}") + + return '\n'.join(lines) + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + command = sys.argv[1] + + if command == 'list-endpoints': + if len(sys.argv) < 3: + print("Usage: openapi_helper.py list-endpoints ") + sys.exit(1) + + spec_file = sys.argv[2] + spec = load_openapi_spec(spec_file) + endpoints = list_endpoints(spec) + print_endpoints_table(endpoints) + + elif command == 'endpoint-details': + if len(sys.argv) < 4: + print("Usage: openapi_helper.py endpoint-details ") + sys.exit(1) + + spec_file = sys.argv[2] + path = sys.argv[3] + spec = load_openapi_spec(spec_file) + details = get_endpoint_details(spec, path) + + if details: + print_endpoint_details(details) + else: + print(f"Endpoint '{path}' not found in OpenAPI spec.") + sys.exit(1) + + elif command == 'generate-dso': + if len(sys.argv) < 4: + print("Usage: openapi_helper.py generate-dso [package-name]") + sys.exit(1) + + spec_file = sys.argv[2] + schema_name = sys.argv[3] + package_name = sys.argv[4] if len(sys.argv) > 4 else "com.levi9.functionaltests.rest.data" + + spec = load_openapi_spec(spec_file) + dso_code = generate_dso_template(spec, schema_name, package_name) + print(dso_code) + + else: + print(f"Unknown command: {command}") + print(__doc__) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/.gitignore b/.gitignore index 1e94c67..c90cf4a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ gradle.bat /out **/classes *.iws +__pycache__ # STS CONFIG .springBeans diff --git a/readme.md b/readme.md index 18d10b6..1d12aad 100644 --- a/readme.md +++ b/readme.md @@ -143,6 +143,74 @@ That will start Restful Booker Platform locally. After everything is up and running you will have Restful Booker Platform available at `http://kube.local`. +## AI-Assisted Test Development (Functional Tests Skill) + +This project includes an AI skill that helps developers write, debug, and review tests following project conventions. The skill is located at `.github/skills/functional-tests-skill/` and automatically guides AI assistants to generate code matching existing patterns. + +### Using with IntelliJ IDEA + +**Prerequisites**: [GitHub Copilot plugin](https://plugins.jetbrains.com/plugin/17718-github-copilot) installed and authenticated. + +**How to use**: +1. Open the Copilot Chat panel (View → Tool Windows → GitHub Copilot Chat) +2. Type `/skill:` to see available skills — `functional-tests-skill` will appear in the list +3. Either: + - Type `/skill:functional-tests-skill` followed by your request, or + - Just ask naturally: "write a test for...", "add coverage for...", "review this test code" + +The skill activates automatically when you mention test-related keywords (feature files, step definitions, page objects, REST API tests, etc.). + +**Example prompts**: +``` +/skill:functional-tests-skill Create a REST API test for creating a pet in the pet store + +Write a UI test for the login page with valid and invalid credentials + +Review this step definition class for best practices + +Add a Scenario Outline for creating multiple rooms with different types +``` + +### Using with VS Code + +**Prerequisites**: [GitHub Copilot extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) and [GitHub Copilot Chat extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat) installed and authenticated. + +**How to use**: +1. Open the Copilot Chat view (View → Open View → GitHub Copilot Chat, or press `Ctrl+Shift+I` / `Cmd+Shift+I`) +2. Type `/` to see available slash commands — `functional-tests-skill` will appear +3. Either: + - Type `/functional-tests-skill` followed by your request, or + - Just ask naturally: "write a test for...", "debug this failing test" + +The skill activates automatically when you mention: writing tests, creating feature files, Gherkin scenarios, step definitions, REST API tests, UI tests, page objects, debugging tests, etc. + +**Example prompts**: +``` +/functional-tests-skill Create a complete REST API test for booking a room + +Generate page object and step definitions for the contact form + +This test is failing with a 400 error - help me debug it + +Review RoomManagementStepdef.java for code quality issues +``` + +**What the skill provides**: +- ✅ Correct project patterns (Service layer, Storage, Page Objects, Step Definitions) +- ✅ Proper annotations (`@Slf4j`, `@Component`, `@Scope("cucumber-glue")`) +- ✅ Correct step annotation styles (Cucumber Expressions for Restful Booker, regex for Pet Store) +- ✅ Wait-based Selenium interactions (no `Thread.sleep()`) +- ✅ AssertJ assertions with descriptive messages +- ✅ Lombok usage (`@Getter`, `@Builder`, etc.) +- ✅ Comprehensive test coverage (happy path + negative cases) +- ✅ Code review using project-specific checklist + +### OpenAPI Helper Script + +The skill includes an [OpenAPI helper script](.github/skills/functional-tests-skill/scripts/README.md) that reads OpenAPI specifications to assist with test development. It can list endpoints, show endpoint details, and generate Java DSO class templates automatically from OpenAPI schemas. + +See [scripts/README.md](.github/skills/functional-tests-skill/scripts/README.md) for complete usage instructions. + ## Codding standards and rules ### Coding Standards