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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/integration_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Integration Tests

on:
pull_request:
branches:
- main
workflow_dispatch:

jobs:
integration-test:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v6

- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.12

- name: Run integration tests
env:
INTEGRATION_TEST: '1'
WIKIDOT_USERNAME: ${{ secrets.WIKIDOT_USERNAME }}
WIKIDOT_PASSWORD: ${{ secrets.WIKIDOT_PASSWORD }}
run: make test-integration
16 changes: 12 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,29 @@ lint-fix:
uv sync --extra lint
uv run ruff check $(FORMAT_DIR) --fix

# テスト関連のコマンド
# テスト関連のコマンド(デフォルトはユニットテストのみ)
test:
uv sync --extra test
uv run pytest tests/ -v
uv run pytest tests/unit/ -v

test-cov:
uv sync --extra test
uv run pytest tests/ -v --cov=src/wikidot --cov-report=term-missing --cov-report=html
uv run pytest tests/unit/ -v --cov=src/wikidot --cov-report=term-missing --cov-report=html --cov-fail-under=80

test-unit:
uv sync --extra test
uv run pytest tests/unit/ -v

test-unit-cov:
uv sync --extra test
uv run pytest tests/unit/ -v --cov=src/wikidot --cov-report=term-missing --cov-report=html --cov-fail-under=80

test-integration:
uv sync --extra test
uv run pytest tests/integration/ -v

.PHONY: build release release_from-develop update-version format format-check commit lint lint-fix test test-cov test-unit test-integration
test-integration-cov:
uv sync --extra test
uv run pytest tests/integration/ -v --cov=src/wikidot --cov-report=term-missing --cov-report=html --cov-fail-under=50

.PHONY: build release release_from-develop update-version format format-check commit lint lint-fix test test-cov test-unit test-unit-cov test-integration test-integration-cov
2 changes: 1 addition & 1 deletion src/wikidot/connector/ajax.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class AjaxModuleConnectorConfig:
"""

request_timeout: int = 20
attempt_limit: int = 3
attempt_limit: int = 5
retry_interval: float = 1.0
max_backoff: float = 60.0
backoff_factor: float = 2.0
Expand Down
86 changes: 86 additions & 0 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 統合テスト

## 概要

このディレクトリには、実際のWikidotサーバー(ukwhatn-ci.wikidot.com)に対する統合テストが含まれています。

## 環境設定

### 必要な環境変数

```bash
export WIKIDOT_USERNAME=your_username
export WIKIDOT_PASSWORD=your_password
```

### テストサイト

- サイト名: `ukwhatn-ci.wikidot.com`
- 要件: テストアカウントがサイトのメンバーであること

## テスト実行

```bash
# 統合テストのみ実行
cd /Users/yuki.c.watanabe/dev/scp/libs/wikidot.py
make test-integration

# または直接pytest
pytest tests/integration/ -v

# 特定のテストファイルを実行
pytest tests/integration/test_page_lifecycle.py -v
```

## テストカバー範囲

| テストファイル | カバー機能 |
|--------------|----------|
| test_site.py | サイト取得、ページ取得 |
| test_page_lifecycle.py | ページ作成、取得、編集、削除 |
| test_page_tags.py | タグ追加、変更、削除 |
| test_page_meta.py | メタ設定、取得、更新、削除 |
| test_page_revision.py | リビジョン履歴取得、最新リビジョン取得 |
| test_page_votes.py | 投票情報取得 |
| test_page_discussion.py | ディスカッション取得、投稿作成 |
| test_forum_category.py | フォーラムカテゴリ一覧、スレッド取得 |
| test_user.py | ユーザー検索、一括取得 |
| test_pm.py | 受信箱/送信箱取得、メッセージ確認 |

## スキップ対象機能

以下の機能は統合テストからスキップされています:

### 1. サイト参加申請
- 理由: テストサイトへの参加申請は手動承認が必要
- 該当API: `site.application.*`

### 2. プライベートメッセージ送信
- 理由: 実ユーザーへのメッセージ送信を避けるため
- 該当API: `client.private_message.send()`
- 備考: 取得のみテスト。事前にInbox/Outboxにメッセージを入れておくこと

### 3. フォーラムカテゴリ/スレッド作成
- 理由: フォーラム構造への永続的な変更を避けるため
- 該当API: `site.forum.create_thread()`
- 備考: ページディスカッションへの投稿のみテスト

### 4. メンバー招待
- 理由: 実ユーザーへの招待を避けるため
- 該当API: `site.member.invite()`

## クリーンアップ戦略

1. 各テストクラスの`setup`フィクスチャでテスト用ページを作成
2. `yield`後のクリーンアップ処理で作成したページを削除
3. 削除失敗時はログ出力して続行
4. ページ命名: `{prefix}-{timestamp}-{random6chars}` 形式で衝突を回避

## 注意事項

- 環境変数が未設定の場合、統合テストは自動的にスキップされます
- テストは各ファイル内で順次実行されます(テスト間に依存関係がある場合があるため)
- APIレート制限に注意してください
- テスト実行後、クリーンアップに失敗したページが残る場合があります
- ページ名プレフィックス(`test-`)で識別可能
- 必要に応じて手動削除してください
134 changes: 134 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""統合テスト用フィクスチャ"""

from __future__ import annotations

import os
import random
import string
import time
from collections.abc import Callable, Generator
from typing import TypeVar

import pytest

T = TypeVar("T")

# 統合テストは環境変数が設定されている場合のみ実行
WIKIDOT_USERNAME = os.environ.get("WIKIDOT_USERNAME")
WIKIDOT_PASSWORD = os.environ.get("WIKIDOT_PASSWORD")
TEST_SITE_UNIX_NAME = "ukwhatn-ci"

# 認証情報が未設定の場合はスキップ
pytestmark = pytest.mark.skipif(
not WIKIDOT_USERNAME or not WIKIDOT_PASSWORD,
reason="WIKIDOT_USERNAME and WIKIDOT_PASSWORD environment variables are required",
)


def generate_page_name(prefix: str = "test") -> str:
"""テスト用ランダムページ名を生成

フォーマット: {prefix}-{timestamp}-{random6chars}
例: test-1703404800-abc123
"""
timestamp = int(time.time())
random_suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
return f"{prefix}-{timestamp}-{random_suffix}"


@pytest.fixture(scope="session")
def credentials() -> dict[str, str]:
"""テスト用認証情報"""
assert WIKIDOT_USERNAME is not None
assert WIKIDOT_PASSWORD is not None
return {
"username": WIKIDOT_USERNAME,
"password": WIKIDOT_PASSWORD,
}


@pytest.fixture(scope="session")
def client(credentials: dict[str, str]):
"""認証済みクライアント(セッション全体で共有)"""
from wikidot import Client

_client = Client(
username=credentials["username"],
password=credentials["password"],
)
yield _client
# セッション終了時にクリーンアップ


@pytest.fixture(scope="session")
def site(client):
"""テストサイト(セッション全体で共有)"""
return client.site.get(TEST_SITE_UNIX_NAME)


@pytest.fixture
def page_name_generator() -> Callable[[str], str]:
"""ページ名生成ヘルパー"""
return generate_page_name


@pytest.fixture
def cleanup_pages(site) -> Generator[list[str], None, None]:
"""テスト終了時にページをクリーンアップ

使用方法:
def test_something(site, cleanup_pages):
page_name = "test-page"
cleanup_pages.append(page_name)
# ... ページ作成
"""
pages_to_cleanup: list[str] = []
yield pages_to_cleanup

for fullname in pages_to_cleanup:
try:
page = site.page.get(fullname, raise_when_not_found=False)
if page is not None:
page.destroy()
except Exception as e:
print(f"Warning: Failed to cleanup page {fullname}: {e}")


def wait_for_condition(
fn: Callable[[], T],
predicate: Callable[[T], bool],
max_retries: int = 5,
interval: float = 1.0,
) -> T:
"""条件が満たされるまでリトライする

Wikidot APIのeventual consistencyを考慮し、
期待する条件が満たされるまでリトライを行う。

Parameters
----------
fn : Callable[[], T]
値を取得する関数
predicate : Callable[[T], bool]
条件を判定する関数
max_retries : int, default 5
最大リトライ回数
interval : float, default 1.0
リトライ間隔(秒)

Returns
-------
T
条件を満たした値

Raises
------
AssertionError
条件を満たさないままリトライ上限に達した場合
"""
for _ in range(max_retries):
time.sleep(interval)
value = fn()
if predicate(value):
return value
raise AssertionError(f"Condition not met after {max_retries} retries")
36 changes: 36 additions & 0 deletions tests/integration/test_forum_category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""フォーラムカテゴリの統合テスト"""

from __future__ import annotations


class TestForumCategory:
"""フォーラムカテゴリ操作テスト"""

def test_1_get_forum_categories(self, site):
"""1. フォーラムカテゴリ一覧取得"""
categories = site.forum.categories
assert categories is not None
# カテゴリがなくても空のコレクションが返る
assert isinstance(categories, list)

def test_2_category_properties(self, site):
"""2. カテゴリプロパティ確認"""
categories = site.forum.categories

# カテゴリがある場合はプロパティを確認
if len(categories) > 0:
category = categories[0]
assert category.id is not None
assert category.title is not None
assert category.site is not None

def test_3_category_threads(self, site):
"""3. カテゴリのスレッド一覧取得"""
categories = site.forum.categories

# カテゴリがある場合はスレッドを取得
if len(categories) > 0:
category = categories[0]
threads = category.threads
assert threads is not None
assert isinstance(threads, list)
Loading