Skip to content

Commit 2411718

Browse files
committed
feat: Add initial API tests for device registration, claiming, and control with mocked dependencies and an in-memory database.
1 parent 7319a27 commit 2411718

6 files changed

Lines changed: 289 additions & 1 deletion

File tree

debug_env.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/bash
2+
source .venv/bin/activate
3+
echo "Running all tests..." > result.log
4+
pytest >> result.log 2>&1
5+
echo "Exit code: $?" >> result.log

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@ pydantic-settings==2.1.0
99
firebase-admin==6.4.0
1010
slowapi==0.1.9
1111
redis==5.0.1
12-
fastapi-limiter==0.1.6
12+
fastapi-limiter==0.1.6
13+
pytest==8.3.4
14+
httpx==0.27.0
15+
pytest-asyncio==0.23.5

result.log

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
Running all tests...
2+
============================= test session starts ==============================
3+
platform linux -- Python 3.11.14, pytest-8.3.4, pluggy-1.6.0
4+
rootdir: /home/bagus/belajar/API-PCB
5+
plugins: anyio-4.12.1, asyncio-0.23.5
6+
asyncio: mode=Mode.STRICT
7+
collected 6 items
8+
9+
tests/test_devices.py ...... [100%]
10+
11+
=============================== warnings summary ===============================
12+
.venv/lib64/python3.11/site-packages/pydantic/_internal/_config.py:271
13+
.venv/lib64/python3.11/site-packages/pydantic/_internal/_config.py:271
14+
/home/bagus/belajar/API-PCB/.venv/lib64/python3.11/site-packages/pydantic/_internal/_config.py:271: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.5/migration/
15+
warnings.warn(DEPRECATION_MESSAGE, DeprecationWarning)
16+
17+
tests/test_devices.py::test_auto_register_success
18+
tests/test_devices.py::test_auto_register_invalid_secret
19+
tests/test_devices.py::test_claim_device_success
20+
tests/test_devices.py::test_claim_device_wrong_pin
21+
tests/test_devices.py::test_control_relay_success_mqtt_publish
22+
tests/test_devices.py::test_control_relay_not_owner
23+
/home/bagus/belajar/API-PCB/.venv/lib64/python3.11/site-packages/httpx/_client.py:680: DeprecationWarning: The 'app' shortcut is now deprecated. Use the explicit style 'transport=WSGITransport(app=...)' instead.
24+
warnings.warn(message, DeprecationWarning)
25+
26+
tests/test_devices.py::test_auto_register_success
27+
tests/test_devices.py::test_claim_device_success
28+
tests/test_devices.py::test_control_relay_success_mqtt_publish
29+
/home/bagus/belajar/API-PCB/.venv/lib64/python3.11/site-packages/starlette/routing.py:72: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited
30+
response = await func(request)
31+
Enable tracemalloc to get traceback where the object was allocated.
32+
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
33+
34+
tests/test_devices.py::test_claim_device_success
35+
tests/test_devices.py::test_claim_device_wrong_pin
36+
/usr/lib64/python3.11/unittest/mock.py:2133: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited
37+
setattr(_type, entry, MagicProxy(entry, self))
38+
Enable tracemalloc to get traceback where the object was allocated.
39+
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
40+
41+
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
42+
======================== 6 passed, 13 warnings in 0.08s ========================
43+
sys:1: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited
44+
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
45+
Exit code: 0

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import os
2+
from dotenv import load_dotenv
3+
4+
# Load env first before importing app modules that rely on os.getenv
5+
load_dotenv()
6+
7+
import pytest
8+
from fastapi.testclient import TestClient
9+
from sqlalchemy import create_engine
10+
from sqlalchemy.orm import sessionmaker
11+
from sqlalchemy.pool import StaticPool
12+
13+
# Import App Utama & Component
14+
from app.main import app
15+
from app.core.database import Base, get_db
16+
from app.api.v1.auth import get_current_user
17+
from app.mqtt.client import mqtt_client # Kita akan mock object ini
18+
19+
# --- 1. SETUP TEST DATABASE (SQLite Memory) ---
20+
# Menggunakan check_same_thread=False karena SQLite akan diakses oleh thread berbeda di test
21+
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
22+
23+
engine = create_engine(
24+
SQLALCHEMY_DATABASE_URL,
25+
connect_args={"check_same_thread": False},
26+
poolclass=StaticPool
27+
)
28+
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
29+
30+
# Fixture untuk membuat tabel & menghapus setelah test selesai (Clean State)
31+
@pytest.fixture(scope="function")
32+
def db_session():
33+
# Create Tables
34+
Base.metadata.create_all(bind=engine)
35+
session = TestingSessionLocal()
36+
try:
37+
yield session
38+
finally:
39+
session.close()
40+
# Drop Tables biar test isolasi
41+
Base.metadata.drop_all(bind=engine)
42+
43+
# --- 2. OVERRIDE DEPENDENCIES ---
44+
# Override get_db agar App pakai SQLite Memory, bukan Postgres Produksi
45+
def override_get_db():
46+
db = TestingSessionLocal()
47+
try:
48+
yield db
49+
finally:
50+
db.close()
51+
52+
# Override Auth (Bypass Firebase)
53+
# Kita anggap semua request datang dari "test_user_uid"
54+
def override_get_current_user():
55+
return "test_user_uid"
56+
57+
# Apply Overrides
58+
app.dependency_overrides[get_db] = override_get_db
59+
app.dependency_overrides[get_current_user] = override_get_current_user
60+
61+
62+
# --- 3. MOCKING MQTT (Biar gak konek ke Broker beneran) ---
63+
from unittest.mock import MagicMock, patch
64+
65+
# --- 3. MOCKING MQTT (Biar gak konek ke Broker beneran) ---
66+
from unittest.mock import MagicMock, patch, AsyncMock
67+
68+
# --- 3. MOCKING MQTT (Biar gak konek ke Broker beneran) ---
69+
from unittest.mock import MagicMock, patch, AsyncMock
70+
71+
@pytest.fixture(autouse=True)
72+
def mock_external_services():
73+
"""
74+
Mock semua service eksternal: MQTT, Redis, Firebase, RateLimiter
75+
agar test berjalan isolasi tanpa butuh koneksi asli.
76+
"""
77+
# Mock RateLimiter call agar awaitable
78+
# PENTING: Harus match signature asli agar FastAPI Dependency Injection jalan benar!
79+
from fastapi import Request, Response
80+
async def mock_limiter_call(self, request: Request, response: Response):
81+
return None
82+
83+
with patch("app.main.start_mqtt"), \
84+
patch("app.main.init_firebase"), \
85+
patch("fastapi_limiter.depends.RateLimiter.__call__", side_effect=mock_limiter_call, autospec=True), \
86+
patch("app.main.redis.from_url") as mock_redis:
87+
88+
# Setup Redis Mock to be AsyncMock so await close() works
89+
mock_conn = AsyncMock()
90+
mock_redis.return_value = mock_conn
91+
92+
yield
93+
94+
@pytest.fixture(autouse=True)
95+
def mock_mqtt():
96+
# Helper specific for app.mqtt.client if needed,
97+
# but app.main.start_mqtt patch above handles the connection/startup.
98+
# This fixture handles the usage of mqtt_client in the code (publish)
99+
100+
# Kita ganti object 'mqtt_client' asli dengan Mock
101+
# Jadi setiap kali kode panggil mqtt_client.publish(), itu cuma dicatat di memory mock
102+
original_publish = mqtt_client.publish
103+
mqtt_client.publish = MagicMock()
104+
105+
# Mock tls_set incase it is called elsewhere (though start_mqtt is patched)
106+
original_tls_set = mqtt_client.tls_set
107+
mqtt_client.tls_set = MagicMock()
108+
109+
yield mqtt_client
110+
111+
# Balikin lagi
112+
mqtt_client.publish = original_publish
113+
mqtt_client.tls_set = original_tls_set
114+
115+
116+
117+
# --- 4. TEST CLIENT FIXTURE ---
118+
@pytest.fixture(scope="function")
119+
def client(db_session):
120+
# Kita butuh db_session di sini cuma buat pastikan tabel udah dibuat
121+
# Karena override sudah di-set global di atas
122+
with TestClient(app) as c:
123+
yield c

tests/test_devices.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
2+
from app.models.device import Device
3+
from unittest.mock import ANY
4+
5+
# ==========================================
6+
# 1. TEST AUTO REGISTER (Device -> Server)
7+
# ==========================================
8+
def test_auto_register_success(client, db_session):
9+
payload = {
10+
"device_id": "TEST_DEV_01",
11+
"pin_code": "1234",
12+
"factory_secret": "RAHASIA_PABRIK_PCB_SERIUS_2026"
13+
}
14+
response = client.post("/api/devices/auto-register", json=payload)
15+
16+
assert response.status_code == 200
17+
assert response.json() == {"message": "Sukses! Alat TEST_DEV_01 didaftarkan."}
18+
19+
# Cek DB apakah masuk
20+
device = db_session.query(Device).filter_by(device_id="TEST_DEV_01").first()
21+
assert device is not None
22+
assert device.pin_code == "1234"
23+
24+
def test_auto_register_invalid_secret(client):
25+
payload = {
26+
"device_id": "HACKER_DEV",
27+
"pin_code": "0000",
28+
"factory_secret": "SALAH_BRO"
29+
}
30+
response = client.post("/api/devices/auto-register", json=payload)
31+
assert response.status_code == 403
32+
33+
34+
# ==========================================
35+
# 2. TEST CLAIM DEVICE (User -> Server)
36+
# ==========================================
37+
def test_claim_device_success(client, db_session):
38+
# Setup data awal (Alat harus ada dulu di DB hasil auto-register)
39+
new_device = Device(device_id="CLAIM_ME", pin_code="9999", device_name="Unknown")
40+
db_session.add(new_device)
41+
db_session.commit()
42+
43+
# User melakukan claim
44+
payload = {"device_id": "CLAIM_ME", "pin_code": "9999"}
45+
response = client.post("/api/devices/claim", json=payload)
46+
47+
assert response.status_code == 200
48+
49+
# Cek apakah owner berubah jadi test_user_uid (dari override auth)
50+
db_session.refresh(new_device)
51+
assert new_device.owner_uid == "test_user_uid"
52+
assert new_device.device_name == "Alat Baru Saya"
53+
54+
def test_claim_device_wrong_pin(client, db_session):
55+
# Setup
56+
new_device = Device(device_id="WRONG_PIN_DEV", pin_code="8888")
57+
db_session.add(new_device)
58+
db_session.commit()
59+
60+
payload = {"device_id": "WRONG_PIN_DEV", "pin_code": "0000"} # PIN SALAH
61+
response = client.post("/api/devices/claim", json=payload)
62+
63+
assert response.status_code == 400
64+
assert "PIN Salah" in response.text
65+
66+
67+
# ==========================================
68+
# 3. TEST CONTROL RELAY (MQTT Logic)
69+
# ==========================================
70+
def test_control_relay_success_mqtt_publish(client, db_session, mock_mqtt):
71+
"""
72+
Memastikan endpoint tidak hanya return 200,
73+
tapi juga BENAR-BENAR memanggil fungsi publish MQTT.
74+
"""
75+
# Setup Device milik User
76+
device = Device(
77+
device_id="MY_LAMP",
78+
pin_code="1111",
79+
owner_uid="test_user_uid" # HARUS MILIK USER YG LOGIN
80+
)
81+
db_session.add(device)
82+
db_session.commit()
83+
84+
# Request Control ON
85+
params = {
86+
"device_id": "MY_LAMP",
87+
"relay_name": "relay_1", # Lampu
88+
"state": True # ON
89+
}
90+
response = client.post("/api/devices/control-relay", params=params)
91+
92+
assert response.status_code == 200
93+
94+
# VERIFIKASI MOCK: Apakah mqtt_client.publish dipanggil?
95+
# Ekspektasi Topik: alat/MY_LAMP/cmd/lampu
96+
# Ekspektasi Payload: ON
97+
mock_mqtt.publish.assert_called_with("alat/MY_LAMP/cmd/lampu", "ON")
98+
99+
def test_control_relay_not_owner(client, db_session):
100+
# Setup Device milik ORANG LAIN
101+
device = Device(
102+
device_id="NEIGHBOR_LAMP",
103+
pin_code="1111",
104+
owner_uid="other_person_uid"
105+
)
106+
db_session.add(device)
107+
db_session.commit()
108+
109+
params = {"device_id": "NEIGHBOR_LAMP", "relay_name": "relay_1", "state": True}
110+
response = client.post("/api/devices/control-relay", params=params)
111+
112+
assert response.status_code == 403 # Forbidden

0 commit comments

Comments
 (0)