Repository: Riminder/hrflow-connectors (language: python)

Issue:
Feature: Craigslist connector to pull job offers into HrFlow boards
This merge request will add in the hrflow_connectors boards a new connector for craigslist job postings:

actions.py which contains the scripts to run to pull and format job offers from craigslist.
The file contains one class, the "CraigslistJobs" class, that inherits **"BoardAction"**classe , which is divided into a "Crawler" function to run the chromium driver which is mandatory to scrap and parse jobs with selenium, a "pull" function that run through all the possible pages and retrieve all job links  and a "format" function that  for each job link formats parsed attributes of job offers into the HrFlow job object format.
-It will also add inside the tests folder, a file test_craiglist_jobs.py to test the connector.


Discussion / hints:

Fix patch:
diff --git a/src/hrflow_connectors/__init__.py b/src/hrflow_connectors/__init__.py
index bc382905d..704d3b2ba 100644
--- a/src/hrflow_connectors/__init__.py
+++ b/src/hrflow_connectors/__init__.py
@@ -8,13 +8,14 @@
 from hrflow_connectors.connectors.recruitee import Recruitee
 from hrflow_connectors.connectors.smartrecruiters import SmartRecruiters
 from hrflow_connectors.connectors.talentsoft import TalentSoft
+from hrflow_connectors.connectors.teamtailor import Teamtailor
 from hrflow_connectors.connectors.workable import Workable
 from hrflow_connectors.core import backend
 from hrflow_connectors.core.connector import hrflow_connectors_manifest  # noqa
 from hrflow_connectors.core.documentation import generate_docs  # noqa
 
 __version__ = "2.0.0"
-__CONNECTORS__ = [SmartRecruiters, TalentSoft, PoleEmploi, Adzuna, Recruitee, Workable, BreezyHR, SAPSuccessFactors, Bullhorn, Ceridian, Greenhouse]
+__CONNECTORS__ = [SmartRecruiters, TalentSoft, PoleEmploi, Adzuna, Recruitee, Workable, BreezyHR, SAPSuccessFactors, Bullhorn, Ceridian, Greenhouse, Teamtailor]
 
 # This makes sure that connector are in module namespace
 # and that the automatic workflow code generation should work
diff --git a/src/hrflow_connectors/connectors/teamtailor/DOCUMENTATION.md b/src/hrflow_connectors/connectors/teamtailor/DOCUMENTATION.md
new file mode 100644
index 000000000..9cebf6d8a
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/DOCUMENTATION.md
@@ -0,0 +1,26 @@
+
+
+
+# TeamTailor Connector
+
+## About TeamTailor:
+
+Teamtailor is the applicant tracking system made for all types of companies. With modern features optimized for you and your candidates, you will get everything you need to recruit successfully.
+
+## Connector features
+
+-   **Pull_Jobs:** Retrieves a list of job IDs from the TeamTailor API, retrieves the corresponding job information and location information for each ID, and sends the formatted data to an HrFlow board.
+-   **Push_Profiles:** Retrieves candidate data from an HrFlow source, formats the data as a JSON object, and posts it to the TeamTailor API to create new candidates.
+
+## Requirements:
+
+To use the connector, you will need the following:
+
+-   A TeamTailor API token and API version, which can be obtained by creating an API key in your TeamTailor account.
+-   An HrFlow board and source, which can be created at [https://hrflow.ai/](https://hrflow.ai/).
+
+## Useful links:
+
+📄Visit [Teamtailor](https://www.teamtailor.com/) to learn more.
+
+💻 [Connector code]([https://github.com/Sprenger07/hrflow-connectors/tree/feature/update-connector-workable/src/hrflow_connectors/connectors/workable](https://github.com/Sprenger07/hrflow-connectors/tree/feature/update-connector-workable/src/hrflow_connectors/connectors/workable)) on our Github.
diff --git a/src/hrflow_connectors/connectors/teamtailor/README.md b/src/hrflow_connectors/connectors/teamtailor/README.md
new file mode 100644
index 000000000..2ce883e7a
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/README.md
@@ -0,0 +1,12 @@
+
+# Teamtailor Connector
+
+> The new way to attract, nurture and hire top talent. Grow faster by focusing on what matters the most — your candidates
+
+
+🔗 https://www.teamtailor.com/
+
+| Actions |
+| ------- |
+| [**Pull jobs**](docs/pull_jobs.md) |
+| [**Push profile**](docs/push_profile.md) |
diff --git a/src/hrflow_connectors/connectors/teamtailor/__init__.py b/src/hrflow_connectors/connectors/teamtailor/__init__.py
new file mode 100644
index 000000000..1748fc54b
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/__init__.py
@@ -0,0 +1,1 @@
+from hrflow_connectors.connectors.teamtailor.connector import Teamtailor
diff --git a/src/hrflow_connectors/connectors/teamtailor/connector.py b/src/hrflow_connectors/connectors/teamtailor/connector.py
new file mode 100644
index 000000000..0236ba350
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/connector.py
@@ -0,0 +1,156 @@
+import re
+import typing as t
+
+from hrflow_connectors.connectors.hrflow.schemas import HrFlowProfile
+from hrflow_connectors.connectors.hrflow.warehouse import (
+    HrFlowJobWarehouse,
+    HrFlowProfileWarehouse,
+)
+from hrflow_connectors.connectors.teamtailor.schema import TeamtailorJob
+from hrflow_connectors.connectors.teamtailor.warehouse import (
+    TeamtailorJobWarehouse,
+    TeamtailorProfileWarehouse,
+)
+from hrflow_connectors.core import (
+    BaseActionParameters,
+    Connector,
+    ConnectorAction,
+    WorkflowType,
+)
+
+
+def remove_html_tags(text: str) -> str:
+    """
+    Remove all HTML tags in a string
+    Args:
+        text (str): text to clean
+    Returns:
+        str: cleaned text (without HTML tags)
+    """
+    return re.sub("<[^<]+?>", "", text)
+
+
+# Returns 0 if not a valid float
+def str_to_float(value: str) -> float:
+    if value.replace(".", "", 1).isdigit():
+        return float(value)
+    return 0
+
+
+def format_job(data: TeamtailorJob) -> t.Dict:
+    """
+    Format a Teamtailor job object into a Hrflow job object
+    Args:
+        data (TeamtailorJob): Teamtailor job object from the list of jobs pulled
+    Returns:
+        HrflowJob: a job in the HrFlow job object form
+    """
+    job = dict()
+    job_data = data.get("job").get("data")
+    attribute = job_data.get("attributes")
+    job["name"] = attribute.get("title")
+    job["reference"] = job_data.get("id")
+    job["summary"] = attribute.get("pitch")
+    job["created_at"] = attribute.get("created-at")
+    job["updated_at"] = attribute.get("updated-at")
+    job["url"] = job_data.get("links").get("careersite-job-url")
+
+    job_location = data.get("job_location")
+    text = job_location.get("text")
+    lat = str_to_float(job_location.get("lat"))
+    lng = str_to_float(job_location.get("lng"))
+    job["location"] = dict(text=text, lat=lat, lng=lng)
+
+    # sections
+    description = remove_html_tags(attribute.get("body"))
+    job["sections"] = [
+        dict(
+            name="teamtailor_description",
+            title="teamtailor_description",
+            description=description,
+        )
+    ]
+
+    # tags
+    t = lambda name, value: dict(name=name, value=value)
+    job["tags"] = [
+        t("start-date", attribute.get("start-date")),
+        t("end-date", attribute.get("end-date")),
+        t("status", attribute.get("status")),
+        t("employment-type", attribute.get("employment-type")),
+        t("employment-level", attribute.get("employment-level")),
+        t("remote-status", attribute.get("remote-status")),
+        t("salary-time-unit", attribute.get("salary-time-unit")),
+        t("min-salary", attribute.get("min-salary")),
+        t("max-salary", attribute.get("max-salary")),
+        t("currency", attribute.get("currency")),
+        t("internal", attribute.get("internal")),
+    ]
+
+    return job
+
+
+def format_profile(data: HrFlowProfile) -> t.Dict:
+    """
+    Format a Hrflow profile object into a Teamtailor profile object
+    Args:
+        data (HrflowProfile): Hrflow Profile to format
+    Returns:
+        Dict[str, Any]: a Teamtailor formatted profile object
+    """
+    profile = dict()
+    info = data.get("info")
+    profile["first-name"] = info.get("first_name")
+    profile["last-name"] = info.get("last_name")
+    profile["email"] = info.get("email")
+    profile["phone"] = info.get("phone")
+    pitch = info.get("summary")
+    profile["pitch"] = pitch[0:139]
+    profile["resume"] = ""
+    if len(data.get("attachments")) >= 2:
+        resume = data.get("attachments")[1]
+        profile["resume"] = resume.get("public_url")
+    profile["sourced"] = "sourced"
+    profile["tags"] = data.get("tags")
+    profile_obj = dict()
+    profile_obj["data"] = {"type": "candidates", "attributes": profile}
+    return profile_obj
+
+
+DESCRIPTION = (
+    "The new way to attract, nurture and hire top talent. Grow faster by focusing on"
+    " what matters the most — your candidates"
+)
+
+Teamtailor = Connector(
+    name="Teamtailor",
+    description=DESCRIPTION,
+    url="https://www.teamtailor.com/",
+    actions=[
+        ConnectorAction(
+            name="pull_jobs",
+            trigger_type=WorkflowType.pull,
+            description=(
+                "Retrieve all jobs via the ***Teamtailor*** API and send them"
+                " to an ***Hrflow.ai Board***."
+            ),
+            parameters=BaseActionParameters.with_defaults(
+                "ReadJobsActionParameters", format=format_job
+            ),
+            origin=TeamtailorJobWarehouse,
+            target=HrFlowJobWarehouse,
+        ),
+        ConnectorAction(
+            name="push_profile",
+            trigger_type=WorkflowType.catch,
+            description=(
+                "Writes a profile from an Hrflow.ai Source to Teamtailor via the API"
+            ),
+            parameters=BaseActionParameters.with_defaults(
+                "WriteProfileActionParameters", format=format_profile
+            ),
+            origin=HrFlowProfileWarehouse,
+            target=TeamtailorProfileWarehouse,
+        ),
+    ],
+)
diff --git a/src/hrflow_connectors/connectors/teamtailor/docs/pull_jobs.md b/src/hrflow_connectors/connectors/teamtailor/docs/pull_jobs.md
new file mode 100644
index 000000000..9eee0f6f3
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/docs/pull_jobs.md
@@ -0,0 +1,72 @@
+
+# Pull jobs
+`Teamtailor Jobs` :arrow_right: `HrFlow.ai Jobs`
+
+Retrieve all jobs via the ***Teamtailor*** API and send them to an ***Hrflow.ai Board***.
+
+
+
+## Action Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `logics`  | `typing.List[typing.Callable[[typing.Dict], typing.Optional[typing.Dict]]]` | [] | List of logic functions |
+| `format`  | `typing.Callable[[typing.Dict], typing.Dict]` | [`format_job`](../connector.py#L40) | Formatting function |
+| `read_mode`  | `str` | ReadMode.sync | If 'incremental' then `read_from` of the last run is given to Origin Warehouse during read. **The actual behavior depends on implementation of read**. In 'sync' mode `read_from` is neither fetched nor given to Origin Warehouse during read. |
+
+## Source Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `Authorization` :red_circle: | `str` | None | Authorisation token used to access Teamtailor API |
+| `X_Api_Version` :red_circle: | `str` | None | Dated version of the API |
+| `filter_status`  | `str` | None | Posting status of a job. One of ['NONE', 'HYBRID', 'TEMPORARY', 'FULLY'] |
+| `filter_feed`  | `str` | None | Status of a job. One of ['PUBLIC', 'PRIVATE'] |
+
+## Destination Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `api_secret` :red_circle: | `str` | None | X-API-KEY used to access HrFlow.ai API |
+| `api_user` :red_circle: | `str` | None | X-USER-EMAIL used to access HrFlow.ai API |
+| `board_key` :red_circle: | `str` | None | HrFlow.ai board key |
+| `sync`  | `bool` | True | When enabled only pushed jobs will remain in the board |
+| `update_content`  | `bool` | False | When enabled jobs already present in the board are updated |
+| `enrich_with_parsing`  | `bool` | False | When enabled jobs are enriched with HrFlow.ai parsing |
+
+:red_circle: : *required*
+
+## Example
+
+```python
+import logging
+from hrflow_connectors import Teamtailor
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+Teamtailor.pull_jobs(
+    workflow_id="some_string_identifier",
+    action_parameters=dict(
+        logics=[],
+        format=lambda *args, **kwargs: None # Put your code logic here,
+        read_mode=ReadMode.sync,
+    ),
+    origin_parameters=dict(
+        Authorization="your_Authorization",
+        X_Api_Version="your_X_Api_Version",
+        filter_status="NONE",
+        filter_feed="PUBLIC",
+    ),
+    target_parameters=dict(
+        api_secret="your_api_secret",
+        api_user="your_api_user",
+        board_key="your_board_key",
+        sync=True,
+        update_content=False,
+        enrich_with_parsing=False,
+    )
+)
+```
\ No newline at end of file
diff --git a/src/hrflow_connectors/connectors/teamtailor/docs/push_profile.md b/src/hrflow_connectors/connectors/teamtailor/docs/push_profile.md
new file mode 100644
index 000000000..bc639c546
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/docs/push_profile.md
@@ -0,0 +1,64 @@
+
+# Push profile
+`HrFlow.ai Profiles` :arrow_right: `Teamtailor Profiles`
+
+Writes a profile from an Hrflow.ai Source to Teamtailor via the API
+
+
+
+## Action Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `logics`  | `typing.List[typing.Callable[[typing.Dict], typing.Optional[typing.Dict]]]` | [] | List of logic functions |
+| `format`  | `typing.Callable[[typing.Dict], typing.Dict]` | [`format_profile`](../connector.py#L93) | Formatting function |
+| `read_mode`  | `str` | ReadMode.sync | If 'incremental' then `read_from` of the last run is given to Origin Warehouse during read. **The actual behavior depends on implementation of read**. In 'sync' mode `read_from` is neither fetched nor given to Origin Warehouse during read. |
+
+## Source Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `api_secret` :red_circle: | `str` | None | X-API-KEY used to access HrFlow.ai API |
+| `api_user` :red_circle: | `str` | None | X-USER-EMAIL used to access HrFlow.ai API |
+| `source_key` :red_circle: | `str` | None | HrFlow.ai source key |
+| `profile_key` :red_circle: | `str` | None | HrFlow.ai profile key |
+
+## Destination Parameters
+
+| Field | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `Authorization` :red_circle: | `str` | None | Authorisation used to access Teamtailor API |
+| `X_Api_Version` :red_circle: | `str` | None | Dated version of the API |
+
+:red_circle: : *required*
+
+## Example
+
+```python
+import logging
+from hrflow_connectors import Teamtailor
+from hrflow_connectors.core import ReadMode
+
+
+logging.basicConfig(level=logging.INFO)
+
+
+Teamtailor.push_profile(
+    workflow_id="some_string_identifier",
+    action_parameters=dict(
+        logics=[],
+        format=lambda *args, **kwargs: None # Put your code logic here,
+        read_mode=ReadMode.sync,
+    ),
+    origin_parameters=dict(
+        api_secret="your_api_secret",
+        api_user="your_api_user",
+        source_key="your_source_key",
+        profile_key="your_profile_key",
+    ),
+    target_parameters=dict(
+        Authorization="your_Authorization",
+        X_Api_Version="your_X_Api_Version",
+    )
+)
+```
\ No newline at end of file
diff --git a/src/hrflow_connectors/connectors/teamtailor/schema.py b/src/hrflow_connectors/connectors/teamtailor/schema.py
new file mode 100644
index 000000000..689b80712
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/schema.py
@@ -0,0 +1,40 @@
+from typing import Any, List, Optional
+
+from pydantic import BaseModel, Field
+
+
+class TeamtailorJobAttribute(BaseModel):
+    title: str
+    pitch: Optional[str]
+    body: str = Field(..., description="job description")
+    created_at: str = Field(..., alias="created-at")
+    updated_at: Optional[str] = Field(..., alias="updated-at")
+    status: Optional[str]
+    tags: List[str]
+    remote_status: Optional[str] = Field(..., alias="remote-status")
+    currency: Optional[str]
+    salary_time_unit: Optional[str] = Field(None, alias="salary-time-unit")
+    min_salary: Optional[int] = Field(..., alias="min-salary")
+    max_salary: Optional[int] = Field(..., alias="max-salary")
+    employment_type: Optional[str] = Field(None, alias="employment-type")
+    employment_level: Optional[str] = Field(None, alias="employment-level")
+    internal: Optional[bool]
+    start_date: Optional[str] = Field(None, alias="start-date")
+    end_date: Optional[str] = Field(None, alias="end-date")
+
+
+class TeamtailorJob(BaseModel):
+    attributes: TeamtailorJobAttribute
+    links: Optional[dict]
+    id: Any
+
+
+class TeamtailorCandidateAttribute(BaseModel):
+    first_name: str = Field(..., alias="first-name")
+    last_name: str = Field(..., alias="last-name")
+    email: str
+    phone: Optional[str]
+    pitch: Optional[str] = Field(None, description="summary")
+    resume: Optional[str] = Field(None, description="candidate profile resume")
+    sourced: Optional[bool]
+    tags: Optional[List]
diff --git a/src/hrflow_connectors/connectors/teamtailor/warehouse.py b/src/hrflow_connectors/connectors/teamtailor/warehouse.py
new file mode 100644
index 000000000..d584d7526
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/warehouse.py
@@ -0,0 +1,231 @@
+import enum
+import json
+import typing as t
+from logging import LoggerAdapter
+
+import requests
+from pydantic import Field
+
+from hrflow_connectors.connectors.teamtailor.schema import (
+    TeamtailorCandidateAttribute,
+    TeamtailorJob,
+)
+from hrflow_connectors.core import (
+    DataType,
+    FieldType,
+    ParametersModel,
+    ReadMode,
+    Warehouse,
+    WarehouseReadAction,
+    WarehouseWriteAction,
+)
+
+GET_ALL_JOBS_ENDPOINT = "https://api.teamtailor.com/v1/jobs"
+GET_JOB_ENDPOINT = "https://api.teamtailor.com/v1/jobs"
+POST_CANDIDATE_ENDPOINT = "https://api.teamtailor.com/v1/candidates"
+
+
+class RemoteStatus(str, enum.Enum):
+    none = "NONE"
+    hybrid = "HYBRID"
+    temporary = "TEMPORARY"
+    fully = "FULLY"
+
+
+class JobFeed(str, enum.Enum):
+    public = "PUBLIC"
+    private = "PRIVATE"
+
+
+class WriteProfilesParameters(ParametersModel):
+    Authorization: str = Field(
+        ...,
+        description="Authorisation used to access Teamtailor API",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    X_Api_Version: str = Field(
+        ..., description="Dated version of the API", field_type=FieldType.Other
+    )
+
+
+class ReadJobsParameters(ParametersModel):
+    Authorization: str = Field(
+        ...,
+        description="Authorisation token used to access Teamtailor API",
+        repr=False,
+        field_type=FieldType.Auth,
+    )
+
+    X_Api_Version: str = Field(
+        ..., description="Dated version of the API", field_type=FieldType.Other
+    )
+
+    filter_status: t.Optional[RemoteStatus] = Field(
+        None,
+        description="Posting status of a job. One of {}".format(
+            [e.value for e in RemoteStatus]
+        ),
+        field_type=FieldType.QueryParam,
+    )
+    filter_feed: t.Optional[JobFeed] = Field(
+        None,
+        description="Status of a job. One of {}".format([e.value for e in JobFeed]),
+        field_type=FieldType.QueryParam,
+    )
+
+
+def enrich_location(job_id: str, headers: t.Dict) -> t.Dict:
+    """
+    Get_location sends a request to get the job location from its API endpoint
+    """
+    response = requests.request(
+        "GET",
+        "https://api.teamtailor.com/v1/jobs/{}/location".format(job_id),
+        headers=headers,
+        data={},
+    )
+
+    if not response.ok:
+        raise Exception(
+            response,
+            message="Failed to get job location from Teamtailor.",
+        )
+
+    location = response.json().get("data")
+    if location is not None:
+        location_attribute = location.get("attributes")
+        text = location_attribute["address"]
+        city = location_attribute["city"]
+        country = location_attribute["country"]
+        headquarters = location_attribute["headquarters"]
+        lat = location_attribute["lat"]
+        lng = location_attribute["long"]
+        zip = location_attribute["zip"]
+        name = location_attribute["name"]
+        geojson = dict(
+            city=city,
+            country=country,
+            headquarters=headquarters,
+            zip=zip,
+            name=name,
+        )
+        location_obj = dict(text=text, geojson=geojson, lat=lat, lng=lng)
+        return location_obj
+    return dict(text="", geojson="", lat="", lng="")
+
+
+def read(
+    adapter: LoggerAdapter,
+    parameters: ReadJobsParameters,
+    read_mode: t.Optional[ReadMode] = None,
+    read_from: t.Optional[str] = None,
+) -> t.Iterable[t.Dict]:
+    headers = {
+        "Authorization": f"Token token={parameters.Authorization}",
+        "X-Api-Version": parameters.X_Api_Version,
+    }
+
+    params = dict()
+    params["filter[remote-status]"] = parameters.filter_status
+    params["filter[feed]"] = parameters.filter_feed
+
+    url = GET_ALL_JOBS_ENDPOINT
+    all_jobs = []
+    while True:
+        response = requests.request("GET", url, headers=headers, data={}, params=params)
+        if response.status_code // 100 != 2:
+            adapter.error(
+                "Failed to pull jobs from Teamtailor status_code={} response={}".format(
+                    response.status_code, response.text
+                )
+            )
+            raise Exception("Failed to pull jobs from Teamtailor")
+        response = response.json()
+        jobs = response.get("data")
+
+        all_jobs += jobs
+        if response.get("links").get("next") is None:
+            break
+        else:
+            url = response.get("links").get("next")
+
+    adapter.info("Pulling {} jobs from Teamtailor API".format(len(all_jobs)))
+
+    for job in all_jobs:
+        full_job_response = requests.request(
+            "GET",
+            "{}/{}".format(GET_JOB_ENDPOINT, job.get("id")),
+            headers=headers,
+            data={},
+        )
+
+        if full_job_response.status_code // 100 != 2:
+            adapter.error(
+                "Failed to pull jobs details from Teamtailor job_id={}"
+                " status_code={} response={}".format(
+                    job["id"],
+                    full_job_response.status_code,
+                    full_job_response.text,
+                )
+            )
+            raise Exception("Failed to pull jobs from Teamtailor")
+
+        # Enrich full_job_response
+        job_location = enrich_location(job.get("id"), headers)
+        job_and_location = dict(job=full_job_response.json(), job_location=job_location)
+        yield job_and_location
+
+
+def write(
+    adapter: LoggerAdapter,
+    parameters: WriteProfilesParameters,
+    profiles: t.Iterable[t.Dict],
+) -> t.List[t.Dict]:
+    adapter.info("Pushing {} profiles".format(len(profiles)))
+    failed_profiles = []
+
+    for profile in profiles:
+        headers = {
+            "Authorization": f"Token token={parameters.Authorization}",
+            "X-Api-Version": parameters.X_Api_Version,
+            "Content-Type": "application/vnd.api+json",
+        }
+        response = requests.request(
+            "POST", POST_CANDIDATE_ENDPOINT, headers=headers, data=json.dumps(profile)
+        )
+
+        if response.status_code // 100 != 2:
+            adapter.error(
+                "Failed to push profile to Teamtailor, "
+                " status_code={} response={}".format(
+                    response.status_code,
+                    response.text,
+                )
+            )
+            failed_profiles.append(profile)
+    return failed_profiles
+
+
+TeamtailorJobWarehouse = Warehouse(
+    name="Teamtailor Jobs",
+    data_schema=TeamtailorJob,
+    data_type=DataType.job,
+    read=WarehouseReadAction(
+        parameters=ReadJobsParameters,
+        function=read,
+        endpoints=[],
+    ),
+)
+
+TeamtailorProfileWarehouse = Warehouse(
+    name="Teamtailor Profiles",
+    data_schema=TeamtailorCandidateAttribute,
+    data_type=DataType.profile,
+    write=WarehouseWriteAction(
+        parameters=WriteProfilesParameters,
+        function=write,
+        endpoints=[],
+    ),
+)


Test patch:
diff --git a/src/hrflow_connectors/connectors/teamtailor/test-config.yaml b/src/hrflow_connectors/connectors/teamtailor/test-config.yaml
new file mode 100644
index 000000000..8ea5f2c87
--- /dev/null
+++ b/src/hrflow_connectors/connectors/teamtailor/test-config.yaml
@@ -0,0 +1,71 @@
+warehouse:
+  TeamtailorJobWarehouse:
+    read:
+      - parameters:
+          Authorization: $__AUTHORIZATION
+          X_Api_Version: $__X_API_VERSION
+actions:
+  push_profile:
+    - id: valid_parameters
+      origin_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+        source_key: $__SOURCE_KEY
+        profile_key: $__PROFILE_KEY
+      target_parameters:
+        Authorization: $__AUTHORIZATION
+        X_Api_Version: $__X_API_VERSION
+      status: success
+  pull_jobs:
+    - id: valid_parameters
+      origin_parameters:
+        Authorization: $__AUTHORIZATION
+        X_Api_Version: $__X_API_VERSION
+        filter[remote-status]: none
+      target_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+        board_key: $__BOARD_KEY
+      status: success
+    - id: not_x_smart_token
+      origin_parameters:
+        X_Api_Version: $__X_API_VERSION
+        posting_status: PUBLIC
+      target_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+        board_key: $__BOARD_KEY
+      status: fatal
+      reason: bad_origin_parameters
+    - id: no_hrflow_board_key
+      origin_parameters:
+        Authorization: $__AUTHORIZATION
+        X_Api_Version: $__X_API_VERSION
+        posting_status: PUBLIC
+      target_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+      status: fatal
+      reason: bad_target_parameters
+    - id: invalid_x_smart_token
+      origin_parameters:
+        Authorization: bad_smart_token
+        X_Api_Version: $__X_API_VERSION
+        posting_status: PUBLIC
+      target_parameters:
+        api_secret: $__API_SECRET
+        api_user: $__API_USER
+        board_key: $__BOARD_KEY
+      status: fatal
+      reason: read_failure
+    - id: invalid_hrflow_api_secret
+      origin_parameters:
+        Authorization: $__AUTHORIZATION
+        X_Api_Version: $__X_API_VERSION
+        posting_status: PUBLIC
+      target_parameters:
+        api_secret: bad_api_secret
+        api_user: $__API_USER
+        board_key: $__BOARD_KEY
+      status: fatal
+      reason: write_failure