6 Commits

Author SHA1 Message Date
Felipe Cardoso
664415111a test(backend): add comprehensive tests for OAuth and agent endpoints
- Added tests for OAuth provider admin and consent endpoints covering edge cases.
- Extended agent-related tests to handle incorrect project associations and lifecycle state transitions.
- Introduced tests for sprint status transitions and validation checks.
- Improved multiline formatting consistency across all test functions.
2026-01-03 01:44:11 +01:00
Felipe Cardoso
acd18ff694 chore(backend): standardize multiline formatting across modules
Reformatted multiline function calls, object definitions, and queries for improved code readability and consistency. Adjusted imports and constraints where necessary.
2026-01-03 01:35:18 +01:00
Felipe Cardoso
da5affd613 fix(frontend): remove locale-dependent routing and migrate to centralized locale-aware router
- Replaced `next/navigation` with `@/lib/i18n/routing` across components, pages, and tests.
- Removed redundant `locale` props from `ProjectWizard` and related pages.
- Updated navigation to exclude explicit `locale` in paths.
- Refactored tests to use mocks from `next-intl/navigation`.
2026-01-03 01:34:53 +01:00
Felipe Cardoso
a79d923dc1 test(frontend): improve test coverage and update edge case handling
- Refactor tests to handle empty `model_params` in AgentTypeForm.
- Add return type annotations (`: never`) for throwing functions in ErrorBoundary tests.
- Mock `useAuth` in home page tests for consistent auth state handling.
- Update Header test to validate updated `/dashboard` link.
2026-01-03 01:19:35 +01:00
Felipe Cardoso
c72f6aa2f9 fix(frontend): redirect authenticated users to dashboard from landing page
- Added auth check in landing page using `useAuth`.
- Redirect authenticated users to `/dashboard`.
- Display blank screen during auth verification or redirection.
2026-01-03 01:12:58 +01:00
Felipe Cardoso
4f24cebf11 chore(frontend): improve code formatting for readability
Standardize multiline formatting across components, tests, and API hooks for better consistency and clarity:
- Adjusted function and object property indentation.
- Updated tests and components to align with clean coding practices.
2026-01-03 01:12:51 +01:00
81 changed files with 2238 additions and 701 deletions

View File

@@ -40,6 +40,7 @@ def include_object(object, name, type_, reflected, compare_to):
return False return False
return True return True
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
if config.config_file_name is not None: if config.config_file_name is not None:

View File

@@ -5,6 +5,7 @@ Revises:
Create Date: 2025-11-27 09:08:09.464506 Create Date: 2025-11-27 09:08:09.464506
""" """
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
@@ -12,7 +13,7 @@ from alembic import op
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '0001' revision: str = "0001"
down_revision: str | None = None down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
@@ -20,243 +21,426 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('oauth_states', op.create_table(
sa.Column('state', sa.String(length=255), nullable=False), "oauth_states",
sa.Column('code_verifier', sa.String(length=128), nullable=True), sa.Column("state", sa.String(length=255), nullable=False),
sa.Column('nonce', sa.String(length=255), nullable=True), sa.Column("code_verifier", sa.String(length=128), nullable=True),
sa.Column('provider', sa.String(length=50), nullable=False), sa.Column("nonce", sa.String(length=255), nullable=True),
sa.Column('redirect_uri', sa.String(length=500), nullable=True), sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=True), sa.Column("redirect_uri", sa.String(length=500), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), sa.Column("user_id", sa.UUID(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), sa.Column("id", sa.UUID(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index(op.f('ix_oauth_states_state'), 'oauth_states', ['state'], unique=True) op.create_index(
op.create_table('organizations', op.f("ix_oauth_states_state"), "oauth_states", ["state"], unique=True
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_organizations_is_active'), 'organizations', ['is_active'], unique=False) op.create_table(
op.create_index(op.f('ix_organizations_name'), 'organizations', ['name'], unique=False) "organizations",
op.create_index('ix_organizations_name_active', 'organizations', ['name', 'is_active'], unique=False) sa.Column("name", sa.String(length=255), nullable=False),
op.create_index(op.f('ix_organizations_slug'), 'organizations', ['slug'], unique=True) sa.Column("slug", sa.String(length=255), nullable=False),
op.create_index('ix_organizations_slug_active', 'organizations', ['slug', 'is_active'], unique=False) sa.Column("description", sa.Text(), nullable=True),
op.create_table('users', sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False), sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('password_hash', sa.String(length=255), nullable=True), sa.Column("id", sa.UUID(), nullable=False),
sa.Column('first_name', sa.String(length=100), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column('last_name', sa.String(length=100), nullable=True), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column('phone_number', sa.String(length=20), nullable=True), sa.PrimaryKeyConstraint("id"),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_superuser', sa.Boolean(), nullable=False),
sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('locale', sa.String(length=10), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_users_deleted_at'), 'users', ['deleted_at'], unique=False) op.create_index(
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) op.f("ix_organizations_is_active"), "organizations", ["is_active"], unique=False
op.create_index(op.f('ix_users_is_active'), 'users', ['is_active'], unique=False)
op.create_index(op.f('ix_users_is_superuser'), 'users', ['is_superuser'], unique=False)
op.create_index(op.f('ix_users_locale'), 'users', ['locale'], unique=False)
op.create_table('oauth_accounts',
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('provider_user_id', sa.String(length=255), nullable=False),
sa.Column('provider_email', sa.String(length=255), nullable=True),
sa.Column('access_token_encrypted', sa.String(length=2048), nullable=True),
sa.Column('refresh_token_encrypted', sa.String(length=2048), nullable=True),
sa.Column('token_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider', 'provider_user_id', name='uq_oauth_provider_user')
) )
op.create_index(op.f('ix_oauth_accounts_provider'), 'oauth_accounts', ['provider'], unique=False) op.create_index(
op.create_index(op.f('ix_oauth_accounts_provider_email'), 'oauth_accounts', ['provider_email'], unique=False) op.f("ix_organizations_name"), "organizations", ["name"], unique=False
op.create_index(op.f('ix_oauth_accounts_user_id'), 'oauth_accounts', ['user_id'], unique=False)
op.create_index('ix_oauth_accounts_user_provider', 'oauth_accounts', ['user_id', 'provider'], unique=False)
op.create_table('oauth_clients',
sa.Column('client_id', sa.String(length=64), nullable=False),
sa.Column('client_secret_hash', sa.String(length=255), nullable=True),
sa.Column('client_name', sa.String(length=255), nullable=False),
sa.Column('client_description', sa.String(length=1000), nullable=True),
sa.Column('client_type', sa.String(length=20), nullable=False),
sa.Column('redirect_uris', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('allowed_scopes', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('access_token_lifetime', sa.String(length=10), nullable=False),
sa.Column('refresh_token_lifetime', sa.String(length=10), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('owner_user_id', sa.UUID(), nullable=True),
sa.Column('mcp_server_url', sa.String(length=2048), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_oauth_clients_client_id'), 'oauth_clients', ['client_id'], unique=True) op.create_index(
op.create_index(op.f('ix_oauth_clients_is_active'), 'oauth_clients', ['is_active'], unique=False) "ix_organizations_name_active",
op.create_table('user_organizations', "organizations",
sa.Column('user_id', sa.UUID(), nullable=False), ["name", "is_active"],
sa.Column('organization_id', sa.UUID(), nullable=False), unique=False,
sa.Column('role', sa.Enum('OWNER', 'ADMIN', 'MEMBER', 'GUEST', name='organizationrole'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('custom_permissions', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'organization_id')
) )
op.create_index('ix_user_org_org_active', 'user_organizations', ['organization_id', 'is_active'], unique=False) op.create_index(
op.create_index('ix_user_org_role', 'user_organizations', ['role'], unique=False) op.f("ix_organizations_slug"), "organizations", ["slug"], unique=True
op.create_index('ix_user_org_user_active', 'user_organizations', ['user_id', 'is_active'], unique=False)
op.create_index(op.f('ix_user_organizations_is_active'), 'user_organizations', ['is_active'], unique=False)
op.create_table('user_sessions',
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('refresh_token_jti', sa.String(length=255), nullable=False),
sa.Column('device_name', sa.String(length=255), nullable=True),
sa.Column('device_id', sa.String(length=255), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=500), nullable=True),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('location_city', sa.String(length=100), nullable=True),
sa.Column('location_country', sa.String(length=100), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_user_sessions_is_active'), 'user_sessions', ['is_active'], unique=False) op.create_index(
op.create_index('ix_user_sessions_jti_active', 'user_sessions', ['refresh_token_jti', 'is_active'], unique=False) "ix_organizations_slug_active",
op.create_index(op.f('ix_user_sessions_refresh_token_jti'), 'user_sessions', ['refresh_token_jti'], unique=True) "organizations",
op.create_index('ix_user_sessions_user_active', 'user_sessions', ['user_id', 'is_active'], unique=False) ["slug", "is_active"],
op.create_index(op.f('ix_user_sessions_user_id'), 'user_sessions', ['user_id'], unique=False) unique=False,
op.create_table('oauth_authorization_codes',
sa.Column('code', sa.String(length=128), nullable=False),
sa.Column('client_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('redirect_uri', sa.String(length=2048), nullable=False),
sa.Column('scope', sa.String(length=1000), nullable=False),
sa.Column('code_challenge', sa.String(length=128), nullable=True),
sa.Column('code_challenge_method', sa.String(length=10), nullable=True),
sa.Column('state', sa.String(length=256), nullable=True),
sa.Column('nonce', sa.String(length=256), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['oauth_clients.client_id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
) )
op.create_index('ix_oauth_authorization_codes_client_user', 'oauth_authorization_codes', ['client_id', 'user_id'], unique=False) op.create_table(
op.create_index(op.f('ix_oauth_authorization_codes_code'), 'oauth_authorization_codes', ['code'], unique=True) "users",
op.create_index('ix_oauth_authorization_codes_expires_at', 'oauth_authorization_codes', ['expires_at'], unique=False) sa.Column("email", sa.String(length=255), nullable=False),
op.create_table('oauth_consents', sa.Column("password_hash", sa.String(length=255), nullable=True),
sa.Column('user_id', sa.UUID(), nullable=False), sa.Column("first_name", sa.String(length=100), nullable=False),
sa.Column('client_id', sa.String(length=64), nullable=False), sa.Column("last_name", sa.String(length=100), nullable=True),
sa.Column('granted_scopes', sa.String(length=1000), nullable=False), sa.Column("phone_number", sa.String(length=20), nullable=True),
sa.Column('id', sa.UUID(), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), sa.Column("is_superuser", sa.Boolean(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), sa.Column(
sa.ForeignKeyConstraint(['client_id'], ['oauth_clients.client_id'], ondelete='CASCADE'), "preferences", postgresql.JSONB(astext_type=sa.Text()), nullable=True
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), ),
sa.PrimaryKeyConstraint('id') sa.Column("locale", sa.String(length=10), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index('ix_oauth_consents_user_client', 'oauth_consents', ['user_id', 'client_id'], unique=True) op.create_index(op.f("ix_users_deleted_at"), "users", ["deleted_at"], unique=False)
op.create_table('oauth_provider_refresh_tokens', op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
sa.Column('token_hash', sa.String(length=64), nullable=False), op.create_index(op.f("ix_users_is_active"), "users", ["is_active"], unique=False)
sa.Column('jti', sa.String(length=64), nullable=False), op.create_index(
sa.Column('client_id', sa.String(length=64), nullable=False), op.f("ix_users_is_superuser"), "users", ["is_superuser"], unique=False
sa.Column('user_id', sa.UUID(), nullable=False), )
sa.Column('scope', sa.String(length=1000), nullable=False), op.create_index(op.f("ix_users_locale"), "users", ["locale"], unique=False)
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), op.create_table(
sa.Column('revoked', sa.Boolean(), nullable=False), "oauth_accounts",
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column('device_info', sa.String(length=500), nullable=True), sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True), sa.Column("provider_user_id", sa.String(length=255), nullable=False),
sa.Column('id', sa.UUID(), nullable=False), sa.Column("provider_email", sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), sa.Column("access_token_encrypted", sa.String(length=2048), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), sa.Column("refresh_token_encrypted", sa.String(length=2048), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oauth_clients.client_id'], ondelete='CASCADE'), sa.Column("token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.Column("id", sa.UUID(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"provider", "provider_user_id", name="uq_oauth_provider_user"
),
)
op.create_index(
op.f("ix_oauth_accounts_provider"), "oauth_accounts", ["provider"], unique=False
)
op.create_index(
op.f("ix_oauth_accounts_provider_email"),
"oauth_accounts",
["provider_email"],
unique=False,
)
op.create_index(
op.f("ix_oauth_accounts_user_id"), "oauth_accounts", ["user_id"], unique=False
)
op.create_index(
"ix_oauth_accounts_user_provider",
"oauth_accounts",
["user_id", "provider"],
unique=False,
)
op.create_table(
"oauth_clients",
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("client_secret_hash", sa.String(length=255), nullable=True),
sa.Column("client_name", sa.String(length=255), nullable=False),
sa.Column("client_description", sa.String(length=1000), nullable=True),
sa.Column("client_type", sa.String(length=20), nullable=False),
sa.Column(
"redirect_uris", postgresql.JSONB(astext_type=sa.Text()), nullable=False
),
sa.Column(
"allowed_scopes", postgresql.JSONB(astext_type=sa.Text()), nullable=False
),
sa.Column("access_token_lifetime", sa.String(length=10), nullable=False),
sa.Column("refresh_token_lifetime", sa.String(length=10), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("owner_user_id", sa.UUID(), nullable=True),
sa.Column("mcp_server_url", sa.String(length=2048), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_oauth_clients_client_id"), "oauth_clients", ["client_id"], unique=True
)
op.create_index(
op.f("ix_oauth_clients_is_active"), "oauth_clients", ["is_active"], unique=False
)
op.create_table(
"user_organizations",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("organization_id", sa.UUID(), nullable=False),
sa.Column(
"role",
sa.Enum("OWNER", "ADMIN", "MEMBER", "GUEST", name="organizationrole"),
nullable=False,
),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("custom_permissions", sa.String(length=500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["organization_id"], ["organizations.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id", "organization_id"),
)
op.create_index(
"ix_user_org_org_active",
"user_organizations",
["organization_id", "is_active"],
unique=False,
)
op.create_index("ix_user_org_role", "user_organizations", ["role"], unique=False)
op.create_index(
"ix_user_org_user_active",
"user_organizations",
["user_id", "is_active"],
unique=False,
)
op.create_index(
op.f("ix_user_organizations_is_active"),
"user_organizations",
["is_active"],
unique=False,
)
op.create_table(
"user_sessions",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("refresh_token_jti", sa.String(length=255), nullable=False),
sa.Column("device_name", sa.String(length=255), nullable=True),
sa.Column("device_id", sa.String(length=255), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("location_city", sa.String(length=100), nullable=True),
sa.Column("location_country", sa.String(length=100), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_user_sessions_is_active"), "user_sessions", ["is_active"], unique=False
)
op.create_index(
"ix_user_sessions_jti_active",
"user_sessions",
["refresh_token_jti", "is_active"],
unique=False,
)
op.create_index(
op.f("ix_user_sessions_refresh_token_jti"),
"user_sessions",
["refresh_token_jti"],
unique=True,
)
op.create_index(
"ix_user_sessions_user_active",
"user_sessions",
["user_id", "is_active"],
unique=False,
)
op.create_index(
op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False
)
op.create_table(
"oauth_authorization_codes",
sa.Column("code", sa.String(length=128), nullable=False),
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("redirect_uri", sa.String(length=2048), nullable=False),
sa.Column("scope", sa.String(length=1000), nullable=False),
sa.Column("code_challenge", sa.String(length=128), nullable=True),
sa.Column("code_challenge_method", sa.String(length=10), nullable=True),
sa.Column("state", sa.String(length=256), nullable=True),
sa.Column("nonce", sa.String(length=256), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used", sa.Boolean(), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["client_id"], ["oauth_clients.client_id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_oauth_authorization_codes_client_user",
"oauth_authorization_codes",
["client_id", "user_id"],
unique=False,
)
op.create_index(
op.f("ix_oauth_authorization_codes_code"),
"oauth_authorization_codes",
["code"],
unique=True,
)
op.create_index(
"ix_oauth_authorization_codes_expires_at",
"oauth_authorization_codes",
["expires_at"],
unique=False,
)
op.create_table(
"oauth_consents",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("granted_scopes", sa.String(length=1000), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["client_id"], ["oauth_clients.client_id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_oauth_consents_user_client",
"oauth_consents",
["user_id", "client_id"],
unique=True,
)
op.create_table(
"oauth_provider_refresh_tokens",
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("jti", sa.String(length=64), nullable=False),
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("scope", sa.String(length=1000), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked", sa.Boolean(), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("device_info", sa.String(length=500), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["client_id"], ["oauth_clients.client_id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_oauth_provider_refresh_tokens_client_user",
"oauth_provider_refresh_tokens",
["client_id", "user_id"],
unique=False,
)
op.create_index(
"ix_oauth_provider_refresh_tokens_expires_at",
"oauth_provider_refresh_tokens",
["expires_at"],
unique=False,
)
op.create_index(
op.f("ix_oauth_provider_refresh_tokens_jti"),
"oauth_provider_refresh_tokens",
["jti"],
unique=True,
)
op.create_index(
op.f("ix_oauth_provider_refresh_tokens_revoked"),
"oauth_provider_refresh_tokens",
["revoked"],
unique=False,
)
op.create_index(
op.f("ix_oauth_provider_refresh_tokens_token_hash"),
"oauth_provider_refresh_tokens",
["token_hash"],
unique=True,
)
op.create_index(
"ix_oauth_provider_refresh_tokens_user_revoked",
"oauth_provider_refresh_tokens",
["user_id", "revoked"],
unique=False,
) )
op.create_index('ix_oauth_provider_refresh_tokens_client_user', 'oauth_provider_refresh_tokens', ['client_id', 'user_id'], unique=False)
op.create_index('ix_oauth_provider_refresh_tokens_expires_at', 'oauth_provider_refresh_tokens', ['expires_at'], unique=False)
op.create_index(op.f('ix_oauth_provider_refresh_tokens_jti'), 'oauth_provider_refresh_tokens', ['jti'], unique=True)
op.create_index(op.f('ix_oauth_provider_refresh_tokens_revoked'), 'oauth_provider_refresh_tokens', ['revoked'], unique=False)
op.create_index(op.f('ix_oauth_provider_refresh_tokens_token_hash'), 'oauth_provider_refresh_tokens', ['token_hash'], unique=True)
op.create_index('ix_oauth_provider_refresh_tokens_user_revoked', 'oauth_provider_refresh_tokens', ['user_id', 'revoked'], unique=False)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_oauth_provider_refresh_tokens_user_revoked', table_name='oauth_provider_refresh_tokens') op.drop_index(
op.drop_index(op.f('ix_oauth_provider_refresh_tokens_token_hash'), table_name='oauth_provider_refresh_tokens') "ix_oauth_provider_refresh_tokens_user_revoked",
op.drop_index(op.f('ix_oauth_provider_refresh_tokens_revoked'), table_name='oauth_provider_refresh_tokens') table_name="oauth_provider_refresh_tokens",
op.drop_index(op.f('ix_oauth_provider_refresh_tokens_jti'), table_name='oauth_provider_refresh_tokens') )
op.drop_index('ix_oauth_provider_refresh_tokens_expires_at', table_name='oauth_provider_refresh_tokens') op.drop_index(
op.drop_index('ix_oauth_provider_refresh_tokens_client_user', table_name='oauth_provider_refresh_tokens') op.f("ix_oauth_provider_refresh_tokens_token_hash"),
op.drop_table('oauth_provider_refresh_tokens') table_name="oauth_provider_refresh_tokens",
op.drop_index('ix_oauth_consents_user_client', table_name='oauth_consents') )
op.drop_table('oauth_consents') op.drop_index(
op.drop_index('ix_oauth_authorization_codes_expires_at', table_name='oauth_authorization_codes') op.f("ix_oauth_provider_refresh_tokens_revoked"),
op.drop_index(op.f('ix_oauth_authorization_codes_code'), table_name='oauth_authorization_codes') table_name="oauth_provider_refresh_tokens",
op.drop_index('ix_oauth_authorization_codes_client_user', table_name='oauth_authorization_codes') )
op.drop_table('oauth_authorization_codes') op.drop_index(
op.drop_index(op.f('ix_user_sessions_user_id'), table_name='user_sessions') op.f("ix_oauth_provider_refresh_tokens_jti"),
op.drop_index('ix_user_sessions_user_active', table_name='user_sessions') table_name="oauth_provider_refresh_tokens",
op.drop_index(op.f('ix_user_sessions_refresh_token_jti'), table_name='user_sessions') )
op.drop_index('ix_user_sessions_jti_active', table_name='user_sessions') op.drop_index(
op.drop_index(op.f('ix_user_sessions_is_active'), table_name='user_sessions') "ix_oauth_provider_refresh_tokens_expires_at",
op.drop_table('user_sessions') table_name="oauth_provider_refresh_tokens",
op.drop_index(op.f('ix_user_organizations_is_active'), table_name='user_organizations') )
op.drop_index('ix_user_org_user_active', table_name='user_organizations') op.drop_index(
op.drop_index('ix_user_org_role', table_name='user_organizations') "ix_oauth_provider_refresh_tokens_client_user",
op.drop_index('ix_user_org_org_active', table_name='user_organizations') table_name="oauth_provider_refresh_tokens",
op.drop_table('user_organizations') )
op.drop_index(op.f('ix_oauth_clients_is_active'), table_name='oauth_clients') op.drop_table("oauth_provider_refresh_tokens")
op.drop_index(op.f('ix_oauth_clients_client_id'), table_name='oauth_clients') op.drop_index("ix_oauth_consents_user_client", table_name="oauth_consents")
op.drop_table('oauth_clients') op.drop_table("oauth_consents")
op.drop_index('ix_oauth_accounts_user_provider', table_name='oauth_accounts') op.drop_index(
op.drop_index(op.f('ix_oauth_accounts_user_id'), table_name='oauth_accounts') "ix_oauth_authorization_codes_expires_at",
op.drop_index(op.f('ix_oauth_accounts_provider_email'), table_name='oauth_accounts') table_name="oauth_authorization_codes",
op.drop_index(op.f('ix_oauth_accounts_provider'), table_name='oauth_accounts') )
op.drop_table('oauth_accounts') op.drop_index(
op.drop_index(op.f('ix_users_locale'), table_name='users') op.f("ix_oauth_authorization_codes_code"),
op.drop_index(op.f('ix_users_is_superuser'), table_name='users') table_name="oauth_authorization_codes",
op.drop_index(op.f('ix_users_is_active'), table_name='users') )
op.drop_index(op.f('ix_users_email'), table_name='users') op.drop_index(
op.drop_index(op.f('ix_users_deleted_at'), table_name='users') "ix_oauth_authorization_codes_client_user",
op.drop_table('users') table_name="oauth_authorization_codes",
op.drop_index('ix_organizations_slug_active', table_name='organizations') )
op.drop_index(op.f('ix_organizations_slug'), table_name='organizations') op.drop_table("oauth_authorization_codes")
op.drop_index('ix_organizations_name_active', table_name='organizations') op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
op.drop_index(op.f('ix_organizations_name'), table_name='organizations') op.drop_index("ix_user_sessions_user_active", table_name="user_sessions")
op.drop_index(op.f('ix_organizations_is_active'), table_name='organizations') op.drop_index(
op.drop_table('organizations') op.f("ix_user_sessions_refresh_token_jti"), table_name="user_sessions"
op.drop_index(op.f('ix_oauth_states_state'), table_name='oauth_states') )
op.drop_table('oauth_states') op.drop_index("ix_user_sessions_jti_active", table_name="user_sessions")
op.drop_index(op.f("ix_user_sessions_is_active"), table_name="user_sessions")
op.drop_table("user_sessions")
op.drop_index(
op.f("ix_user_organizations_is_active"), table_name="user_organizations"
)
op.drop_index("ix_user_org_user_active", table_name="user_organizations")
op.drop_index("ix_user_org_role", table_name="user_organizations")
op.drop_index("ix_user_org_org_active", table_name="user_organizations")
op.drop_table("user_organizations")
op.drop_index(op.f("ix_oauth_clients_is_active"), table_name="oauth_clients")
op.drop_index(op.f("ix_oauth_clients_client_id"), table_name="oauth_clients")
op.drop_table("oauth_clients")
op.drop_index("ix_oauth_accounts_user_provider", table_name="oauth_accounts")
op.drop_index(op.f("ix_oauth_accounts_user_id"), table_name="oauth_accounts")
op.drop_index(op.f("ix_oauth_accounts_provider_email"), table_name="oauth_accounts")
op.drop_index(op.f("ix_oauth_accounts_provider"), table_name="oauth_accounts")
op.drop_table("oauth_accounts")
op.drop_index(op.f("ix_users_locale"), table_name="users")
op.drop_index(op.f("ix_users_is_superuser"), table_name="users")
op.drop_index(op.f("ix_users_is_active"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_index(op.f("ix_users_deleted_at"), table_name="users")
op.drop_table("users")
op.drop_index("ix_organizations_slug_active", table_name="organizations")
op.drop_index(op.f("ix_organizations_slug"), table_name="organizations")
op.drop_index("ix_organizations_name_active", table_name="organizations")
op.drop_index(op.f("ix_organizations_name"), table_name="organizations")
op.drop_index(op.f("ix_organizations_is_active"), table_name="organizations")
op.drop_table("organizations")
op.drop_index(op.f("ix_oauth_states_state"), table_name="oauth_states")
op.drop_table("oauth_states")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@@ -114,8 +114,13 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# Drop indexes in reverse order # Drop indexes in reverse order
op.drop_index("ix_perf_oauth_auth_codes_expires", table_name="oauth_authorization_codes") op.drop_index(
op.drop_index("ix_perf_oauth_refresh_tokens_expires", table_name="oauth_provider_refresh_tokens") "ix_perf_oauth_auth_codes_expires", table_name="oauth_authorization_codes"
)
op.drop_index(
"ix_perf_oauth_refresh_tokens_expires",
table_name="oauth_provider_refresh_tokens",
)
op.drop_index("ix_perf_user_sessions_expires", table_name="user_sessions") op.drop_index("ix_perf_user_sessions_expires", table_name="user_sessions")
op.drop_index("ix_perf_organizations_slug_lower", table_name="organizations") op.drop_index("ix_perf_organizations_slug_lower", table_name="organizations")
op.drop_index("ix_perf_users_active", table_name="users") op.drop_index("ix_perf_users_active", table_name="users")

View File

@@ -443,9 +443,7 @@ def upgrade() -> None:
), ),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(["parent_id"], ["issues.id"], ondelete="CASCADE"),
["parent_id"], ["issues.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["sprint_id"], ["sprints.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(["sprint_id"], ["sprints.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["assigned_agent_id"], ["agent_instances.id"], ondelete="SET NULL" ["assigned_agent_id"], ["agent_instances.id"], ondelete="SET NULL"
@@ -462,7 +460,9 @@ def upgrade() -> None:
op.create_index("ix_issues_human_assignee", "issues", ["human_assignee"]) op.create_index("ix_issues_human_assignee", "issues", ["human_assignee"])
op.create_index("ix_issues_sprint_id", "issues", ["sprint_id"]) op.create_index("ix_issues_sprint_id", "issues", ["sprint_id"])
op.create_index("ix_issues_due_date", "issues", ["due_date"]) op.create_index("ix_issues_due_date", "issues", ["due_date"])
op.create_index("ix_issues_external_tracker_type", "issues", ["external_tracker_type"]) op.create_index(
"ix_issues_external_tracker_type", "issues", ["external_tracker_type"]
)
op.create_index("ix_issues_sync_status", "issues", ["sync_status"]) op.create_index("ix_issues_sync_status", "issues", ["sync_status"])
op.create_index("ix_issues_closed_at", "issues", ["closed_at"]) op.create_index("ix_issues_closed_at", "issues", ["closed_at"])
# Composite indexes # Composite indexes
@@ -470,7 +470,9 @@ def upgrade() -> None:
op.create_index("ix_issues_project_priority", "issues", ["project_id", "priority"]) op.create_index("ix_issues_project_priority", "issues", ["project_id", "priority"])
op.create_index("ix_issues_project_sprint", "issues", ["project_id", "sprint_id"]) op.create_index("ix_issues_project_sprint", "issues", ["project_id", "sprint_id"])
op.create_index("ix_issues_project_type", "issues", ["project_id", "type"]) op.create_index("ix_issues_project_type", "issues", ["project_id", "type"])
op.create_index("ix_issues_project_agent", "issues", ["project_id", "assigned_agent_id"]) op.create_index(
"ix_issues_project_agent", "issues", ["project_id", "assigned_agent_id"]
)
op.create_index( op.create_index(
"ix_issues_project_status_priority", "ix_issues_project_status_priority",
"issues", "issues",

View File

@@ -32,9 +32,7 @@ api_router.include_router(
api_router.include_router(events.router, tags=["Events"]) api_router.include_router(events.router, tags=["Events"])
# Syndarix domain routers # Syndarix domain routers
api_router.include_router( api_router.include_router(projects.router, prefix="/projects", tags=["Projects"])
projects.router, prefix="/projects", tags=["Projects"]
)
api_router.include_router( api_router.include_router(
agent_types.router, prefix="/agent-types", tags=["Agent Types"] agent_types.router, prefix="/agent-types", tags=["Agent Types"]
) )

View File

@@ -57,8 +57,18 @@ RATE_MULTIPLIER = 100 if IS_TEST else 1
# Valid status transitions for agent lifecycle management # Valid status transitions for agent lifecycle management
VALID_STATUS_TRANSITIONS: dict[AgentStatus, set[AgentStatus]] = { VALID_STATUS_TRANSITIONS: dict[AgentStatus, set[AgentStatus]] = {
AgentStatus.IDLE: {AgentStatus.WORKING, AgentStatus.PAUSED, AgentStatus.TERMINATED}, AgentStatus.IDLE: {AgentStatus.WORKING, AgentStatus.PAUSED, AgentStatus.TERMINATED},
AgentStatus.WORKING: {AgentStatus.IDLE, AgentStatus.WAITING, AgentStatus.PAUSED, AgentStatus.TERMINATED}, AgentStatus.WORKING: {
AgentStatus.WAITING: {AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.PAUSED, AgentStatus.TERMINATED}, AgentStatus.IDLE,
AgentStatus.WAITING,
AgentStatus.PAUSED,
AgentStatus.TERMINATED,
},
AgentStatus.WAITING: {
AgentStatus.IDLE,
AgentStatus.WORKING,
AgentStatus.PAUSED,
AgentStatus.TERMINATED,
},
AgentStatus.PAUSED: {AgentStatus.IDLE, AgentStatus.TERMINATED}, AgentStatus.PAUSED: {AgentStatus.IDLE, AgentStatus.TERMINATED},
AgentStatus.TERMINATED: set(), # Terminal state, no transitions allowed AgentStatus.TERMINATED: set(), # Terminal state, no transitions allowed
} }
@@ -870,9 +880,7 @@ async def terminate_agent(
agent_name = agent.name agent_name = agent.name
# Terminate the agent # Terminate the agent
terminated_agent = await agent_instance_crud.terminate( terminated_agent = await agent_instance_crud.terminate(db, instance_id=agent_id)
db, instance_id=agent_id
)
if not terminated_agent: if not terminated_agent:
raise NotFoundError( raise NotFoundError(
@@ -881,8 +889,7 @@ async def terminate_agent(
) )
logger.info( logger.info(
f"User {current_user.email} terminated agent {agent_name} " f"User {current_user.email} terminated agent {agent_name} (id={agent_id})"
f"(id={agent_id})"
) )
return MessageResponse( return MessageResponse(

View File

@@ -199,7 +199,9 @@ async def stream_project_events(
project_id: UUID, project_id: UUID,
db: "AsyncSession" = Depends(get_db), db: "AsyncSession" = Depends(get_db),
event_bus: EventBus = Depends(get_event_bus), event_bus: EventBus = Depends(get_event_bus),
token: str | None = Query(None, description="Auth token (for EventSource compatibility)"), token: str | None = Query(
None, description="Auth token (for EventSource compatibility)"
),
authorization: str | None = Header(None, alias="Authorization"), authorization: str | None = Header(None, alias="Authorization"),
last_event_id: str | None = Header(None, alias="Last-Event-ID"), last_event_id: str | None = Header(None, alias="Last-Event-ID"),
): ):

View File

@@ -278,9 +278,7 @@ async def list_issues(
assigned_agent_id: UUID | None = Query( assigned_agent_id: UUID | None = Query(
None, description="Filter by assigned agent ID" None, description="Filter by assigned agent ID"
), ),
sync_status: SyncStatus | None = Query( sync_status: SyncStatus | None = Query(None, description="Filter by sync status"),
None, description="Filter by sync status"
),
search: str | None = Query( search: str | None = Query(
None, min_length=1, max_length=100, description="Search in title and body" None, min_length=1, max_length=100, description="Search in title and body"
), ),
@@ -783,9 +781,7 @@ async def assign_issue(
updated_issue = await issue_crud.assign_to_agent( updated_issue = await issue_crud.assign_to_agent(
db, issue_id=issue_id, agent_id=None db, issue_id=issue_id, agent_id=None
) )
logger.info( logger.info(f"User {current_user.email} unassigned issue {issue_id}")
f"User {current_user.email} unassigned issue {issue_id}"
)
if not updated_issue: if not updated_issue:
raise NotFoundError( raise NotFoundError(

View File

@@ -197,10 +197,10 @@ async def list_projects(
status_filter: ProjectStatus | None = Query( status_filter: ProjectStatus | None = Query(
None, alias="status", description="Filter by project status" None, alias="status", description="Filter by project status"
), ),
search: str | None = Query(None, description="Search by name, slug, or description"), search: str | None = Query(
all_projects: bool = Query( None, description="Search by name, slug, or description"
False, description="Show all projects (superuser only)"
), ),
all_projects: bool = Query(False, description="Show all projects (superuser only)"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Any: ) -> Any:
@@ -212,7 +212,9 @@ async def list_projects(
""" """
try: try:
# Determine owner filter based on user role and request # Determine owner filter based on user role and request
owner_id = None if (current_user.is_superuser and all_projects) else current_user.id owner_id = (
None if (current_user.is_superuser and all_projects) else current_user.id
)
projects_data, total = await project_crud.get_multi_with_counts( projects_data, total = await project_crud.get_multi_with_counts(
db, db,
@@ -379,13 +381,15 @@ async def update_project(
_check_project_ownership(project, current_user) _check_project_ownership(project, current_user)
# Update the project # Update the project
updated_project = await project_crud.update(db, db_obj=project, obj_in=project_in) updated_project = await project_crud.update(
logger.info( db, db_obj=project, obj_in=project_in
f"User {current_user.email} updated project {updated_project.slug}"
) )
logger.info(f"User {current_user.email} updated project {updated_project.slug}")
# Get updated project with counts # Get updated project with counts
project_data = await project_crud.get_with_counts(db, project_id=updated_project.id) project_data = await project_crud.get_with_counts(
db, project_id=updated_project.id
)
if not project_data: if not project_data:
# This shouldn't happen, but handle gracefully # This shouldn't happen, but handle gracefully
@@ -551,7 +555,9 @@ async def pause_project(
logger.info(f"User {current_user.email} paused project {project.slug}") logger.info(f"User {current_user.email} paused project {project.slug}")
# Get project with counts # Get project with counts
project_data = await project_crud.get_with_counts(db, project_id=updated_project.id) project_data = await project_crud.get_with_counts(
db, project_id=updated_project.id
)
if not project_data: if not project_data:
raise NotFoundError( raise NotFoundError(
@@ -634,7 +640,9 @@ async def resume_project(
logger.info(f"User {current_user.email} resumed project {project.slug}") logger.info(f"User {current_user.email} resumed project {project.slug}")
# Get project with counts # Get project with counts
project_data = await project_crud.get_with_counts(db, project_id=updated_project.id) project_data = await project_crud.get_with_counts(
db, project_id=updated_project.id
)
if not project_data: if not project_data:
raise NotFoundError( raise NotFoundError(

View File

@@ -320,7 +320,9 @@ async def list_sprints(
return PaginatedResponse(data=sprint_responses, pagination=pagination_meta) return PaginatedResponse(data=sprint_responses, pagination=pagination_meta)
except Exception as e: except Exception as e:
logger.error(f"Error listing sprints for project {project_id}: {e!s}", exc_info=True) logger.error(
f"Error listing sprints for project {project_id}: {e!s}", exc_info=True
)
raise raise
@@ -564,7 +566,9 @@ async def update_sprint(
) )
# Update the sprint # Update the sprint
updated_sprint = await sprint_crud.update(db, db_obj=sprint, obj_in=sprint_update) updated_sprint = await sprint_crud.update(
db, db_obj=sprint, obj_in=sprint_update
)
logger.info( logger.info(
f"User {current_user.id} updated sprint {sprint_id} in project {project_id}" f"User {current_user.id} updated sprint {sprint_id} in project {project_id}"
@@ -1123,7 +1127,9 @@ async def remove_issue_from_sprint(
request: Request, request: Request,
project_id: UUID, project_id: UUID,
sprint_id: UUID, sprint_id: UUID,
issue_id: UUID = Query(..., description="ID of the issue to remove from the sprint"), issue_id: UUID = Query(
..., description="ID of the issue to remove from the sprint"
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Any: ) -> Any:

View File

@@ -243,7 +243,9 @@ class RedisClient:
try: try:
client = await self._get_client() client = await self._get_client()
result = await client.expire(key, ttl) result = await client.expire(key, ttl)
logger.debug(f"Cache expire for key: {key} (TTL: {ttl}s, success: {result})") logger.debug(
f"Cache expire for key: {key} (TTL: {ttl}s, success: {result})"
)
return result return result
except (ConnectionError, TimeoutError) as e: except (ConnectionError, TimeoutError) as e:
logger.error(f"Redis cache_expire failed for key '{key}': {e}") logger.error(f"Redis cache_expire failed for key '{key}': {e}")
@@ -323,9 +325,7 @@ class RedisClient:
return 0 return 0
@asynccontextmanager @asynccontextmanager
async def subscribe( async def subscribe(self, *channels: str) -> AsyncGenerator[PubSub, None]:
self, *channels: str
) -> AsyncGenerator[PubSub, None]:
""" """
Subscribe to one or more channels. Subscribe to one or more channels.
@@ -353,9 +353,7 @@ class RedisClient:
logger.debug(f"Unsubscribed from channels: {channels}") logger.debug(f"Unsubscribed from channels: {channels}")
@asynccontextmanager @asynccontextmanager
async def psubscribe( async def psubscribe(self, *patterns: str) -> AsyncGenerator[PubSub, None]:
self, *patterns: str
) -> AsyncGenerator[PubSub, None]:
""" """
Subscribe to channels matching patterns. Subscribe to channels matching patterns.

View File

@@ -20,7 +20,9 @@ from app.schemas.syndarix import AgentInstanceCreate, AgentInstanceUpdate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstanceUpdate]): class CRUDAgentInstance(
CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstanceUpdate]
):
"""Async CRUD operations for AgentInstance model.""" """Async CRUD operations for AgentInstance model."""
async def create( async def create(
@@ -91,8 +93,12 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
return { return {
"instance": instance, "instance": instance,
"agent_type_name": instance.agent_type.name if instance.agent_type else None, "agent_type_name": instance.agent_type.name
"agent_type_slug": instance.agent_type.slug if instance.agent_type else None, if instance.agent_type
else None,
"agent_type_slug": instance.agent_type.slug
if instance.agent_type
else None,
"project_name": instance.project.name if instance.project else None, "project_name": instance.project.name if instance.project else None,
"project_slug": instance.project.slug if instance.project else None, "project_slug": instance.project.slug if instance.project else None,
"assigned_issues_count": assigned_issues_count, "assigned_issues_count": assigned_issues_count,
@@ -115,9 +121,7 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
) -> tuple[list[AgentInstance], int]: ) -> tuple[list[AgentInstance], int]:
"""Get agent instances for a specific project.""" """Get agent instances for a specific project."""
try: try:
query = select(AgentInstance).where( query = select(AgentInstance).where(AgentInstance.project_id == project_id)
AgentInstance.project_id == project_id
)
if status is not None: if status is not None:
query = query.where(AgentInstance.status == status) query = query.where(AgentInstance.status == status)

View File

@@ -22,17 +22,13 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
async def get_by_slug(self, db: AsyncSession, *, slug: str) -> AgentType | None: async def get_by_slug(self, db: AsyncSession, *, slug: str) -> AgentType | None:
"""Get agent type by slug.""" """Get agent type by slug."""
try: try:
result = await db.execute( result = await db.execute(select(AgentType).where(AgentType.slug == slug))
select(AgentType).where(AgentType.slug == slug)
)
return result.scalar_one_or_none() return result.scalar_one_or_none()
except Exception as e: except Exception as e:
logger.error(f"Error getting agent type by slug {slug}: {e!s}") logger.error(f"Error getting agent type by slug {slug}: {e!s}")
raise raise
async def create( async def create(self, db: AsyncSession, *, obj_in: AgentTypeCreate) -> AgentType:
self, db: AsyncSession, *, obj_in: AgentTypeCreate
) -> AgentType:
"""Create a new agent type with error handling.""" """Create a new agent type with error handling."""
try: try:
db_obj = AgentType( db_obj = AgentType(
@@ -57,16 +53,12 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
error_msg = str(e.orig) if hasattr(e, "orig") else str(e) error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
if "slug" in error_msg.lower(): if "slug" in error_msg.lower():
logger.warning(f"Duplicate slug attempted: {obj_in.slug}") logger.warning(f"Duplicate slug attempted: {obj_in.slug}")
raise ValueError( raise ValueError(f"Agent type with slug '{obj_in.slug}' already exists")
f"Agent type with slug '{obj_in.slug}' already exists"
)
logger.error(f"Integrity error creating agent type: {error_msg}") logger.error(f"Integrity error creating agent type: {error_msg}")
raise ValueError(f"Database integrity error: {error_msg}") raise ValueError(f"Database integrity error: {error_msg}")
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
logger.error( logger.error(f"Unexpected error creating agent type: {e!s}", exc_info=True)
f"Unexpected error creating agent type: {e!s}", exc_info=True
)
raise raise
async def get_multi_with_filters( async def get_multi_with_filters(
@@ -215,9 +207,7 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
return results, total return results, total
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error getting agent types with counts: {e!s}", exc_info=True)
f"Error getting agent types with counts: {e!s}", exc_info=True
)
raise raise
async def get_by_expertise( async def get_by_expertise(

View File

@@ -75,7 +75,9 @@ class CRUDIssue(CRUDBase[Issue, IssueCreate, IssueUpdate]):
.options( .options(
joinedload(Issue.project), joinedload(Issue.project),
joinedload(Issue.sprint), joinedload(Issue.sprint),
joinedload(Issue.assigned_agent).joinedload(AgentInstance.agent_type), joinedload(Issue.assigned_agent).joinedload(
AgentInstance.agent_type
),
) )
.where(Issue.id == issue_id) .where(Issue.id == issue_id)
) )
@@ -449,9 +451,7 @@ class CRUDIssue(CRUDBase[Issue, IssueCreate, IssueUpdate]):
from sqlalchemy import update from sqlalchemy import update
result = await db.execute( result = await db.execute(
update(Issue) update(Issue).where(Issue.sprint_id == sprint_id).values(sprint_id=None)
.where(Issue.sprint_id == sprint_id)
.values(sprint_id=None)
) )
await db.commit() await db.commit()
return result.rowcount return result.rowcount

View File

@@ -2,11 +2,10 @@
"""Async CRUD operations for Project model using SQLAlchemy 2.0 patterns.""" """Async CRUD operations for Project model using SQLAlchemy 2.0 patterns."""
import logging import logging
from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from datetime import UTC, datetime
from sqlalchemy import func, or_, select, update from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -234,9 +233,7 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
Sprint.status == SprintStatus.ACTIVE, Sprint.status == SprintStatus.ACTIVE,
) )
) )
active_sprints = { active_sprints = {row.project_id: row.name for row in active_sprints_result}
row.project_id: row.name for row in active_sprints_result
}
# Combine results # Combine results
results = [ results = [
@@ -251,9 +248,7 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
return results, total return results, total
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error getting projects with counts: {e!s}", exc_info=True)
f"Error getting projects with counts: {e!s}", exc_info=True
)
raise raise
async def get_projects_by_owner( async def get_projects_by_owner(
@@ -293,9 +288,7 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
- Unassigns issues from terminated agents - Unassigns issues from terminated agents
""" """
try: try:
result = await db.execute( result = await db.execute(select(Project).where(Project.id == project_id))
select(Project).where(Project.id == project_id)
)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@@ -361,9 +354,7 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
return project return project
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
logger.error( logger.error(f"Error archiving project {project_id}: {e!s}", exc_info=True)
f"Error archiving project {project_id}: {e!s}", exc_info=True
)
raise raise

View File

@@ -193,9 +193,7 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
try: try:
# Lock the sprint row to prevent concurrent modifications # Lock the sprint row to prevent concurrent modifications
result = await db.execute( result = await db.execute(
select(Sprint) select(Sprint).where(Sprint.id == sprint_id).with_for_update()
.where(Sprint.id == sprint_id)
.with_for_update()
) )
sprint = result.scalar_one_or_none() sprint = result.scalar_one_or_none()
@@ -257,9 +255,7 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
try: try:
# Lock the sprint row to prevent concurrent modifications # Lock the sprint row to prevent concurrent modifications
result = await db.execute( result = await db.execute(
select(Sprint) select(Sprint).where(Sprint.id == sprint_id).with_for_update()
.where(Sprint.id == sprint_id)
.with_for_update()
) )
sprint = result.scalar_one_or_none() sprint = result.scalar_one_or_none()
@@ -308,9 +304,7 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
try: try:
# Lock the sprint row to prevent concurrent modifications # Lock the sprint row to prevent concurrent modifications
result = await db.execute( result = await db.execute(
select(Sprint) select(Sprint).where(Sprint.id == sprint_id).with_for_update()
.where(Sprint.id == sprint_id)
.with_for_update()
) )
sprint = result.scalar_one_or_none() sprint = result.scalar_one_or_none()
@@ -425,7 +419,8 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
{ {
"sprint": sprint, "sprint": sprint,
**counts_map.get( **counts_map.get(
sprint.id, {"issue_count": 0, "open_issues": 0, "completed_issues": 0} sprint.id,
{"issue_count": 0, "open_issues": 0, "completed_issues": 0},
), ),
} }
for sprint in sprints for sprint in sprints

View File

@@ -158,7 +158,11 @@ class Issue(Base, UUIDMixin, TimestampMixin):
Index("ix_issues_project_status", "project_id", "status"), Index("ix_issues_project_status", "project_id", "status"),
Index("ix_issues_project_priority", "project_id", "priority"), Index("ix_issues_project_priority", "project_id", "priority"),
Index("ix_issues_project_sprint", "project_id", "sprint_id"), Index("ix_issues_project_sprint", "project_id", "sprint_id"),
Index("ix_issues_external_tracker_id", "external_tracker_type", "external_issue_id"), Index(
"ix_issues_external_tracker_id",
"external_tracker_type",
"external_issue_id",
),
Index("ix_issues_sync_status", "sync_status"), Index("ix_issues_sync_status", "sync_status"),
Index("ix_issues_project_agent", "project_id", "assigned_agent_id"), Index("ix_issues_project_agent", "project_id", "assigned_agent_id"),
Index("ix_issues_project_type", "project_id", "type"), Index("ix_issues_project_type", "project_id", "type"),

View File

@@ -5,7 +5,17 @@ Sprint model for Syndarix AI consulting platform.
A Sprint represents a time-boxed iteration for organizing and delivering work. A Sprint represents a time-boxed iteration for organizing and delivering work.
""" """
from sqlalchemy import Column, Date, Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint from sqlalchemy import (
Column,
Date,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship

View File

@@ -205,9 +205,7 @@ class SprintCompletedPayload(BaseModel):
sprint_id: UUID = Field(..., description="Sprint ID") sprint_id: UUID = Field(..., description="Sprint ID")
sprint_name: str = Field(..., description="Sprint name") sprint_name: str = Field(..., description="Sprint name")
completed_issues: int = Field(default=0, description="Number of completed issues") completed_issues: int = Field(default=0, description="Number of completed issues")
incomplete_issues: int = Field( incomplete_issues: int = Field(default=0, description="Number of incomplete issues")
default=0, description="Number of incomplete issues"
)
class ApprovalRequestedPayload(BaseModel): class ApprovalRequestedPayload(BaseModel):

View File

@@ -99,9 +99,7 @@ class IssueAssign(BaseModel):
def validate_assignment(self) -> "IssueAssign": def validate_assignment(self) -> "IssueAssign":
"""Ensure only one type of assignee is set.""" """Ensure only one type of assignee is set."""
if self.assigned_agent_id and self.human_assignee: if self.assigned_agent_id and self.human_assignee:
raise ValueError( raise ValueError("Cannot assign to both an agent and a human. Choose one.")
"Cannot assign to both an agent and a human. Choose one."
)
return self return self

View File

@@ -54,22 +54,18 @@ class EventBusError(Exception):
"""Base exception for EventBus errors.""" """Base exception for EventBus errors."""
class EventBusConnectionError(EventBusError): class EventBusConnectionError(EventBusError):
"""Raised when connection to Redis fails.""" """Raised when connection to Redis fails."""
class EventBusPublishError(EventBusError): class EventBusPublishError(EventBusError):
"""Raised when publishing an event fails.""" """Raised when publishing an event fails."""
class EventBusSubscriptionError(EventBusError): class EventBusSubscriptionError(EventBusError):
"""Raised when subscribing to channels fails.""" """Raised when subscribing to channels fails."""
class EventBus: class EventBus:
""" """
EventBus for Redis Pub/Sub communication. EventBus for Redis Pub/Sub communication.

View File

@@ -343,7 +343,9 @@ class OAuthService:
await oauth_account.update_tokens( await oauth_account.update_tokens(
db, db,
account=existing_oauth, account=existing_oauth,
access_token_encrypted=token.get("access_token"), refresh_token_encrypted=token.get("refresh_token"), token_expires_at=datetime.now(UTC) access_token_encrypted=token.get("access_token"),
refresh_token_encrypted=token.get("refresh_token"),
token_expires_at=datetime.now(UTC)
+ timedelta(seconds=token.get("expires_in", 3600)), + timedelta(seconds=token.get("expires_in", 3600)),
) )
@@ -375,7 +377,9 @@ class OAuthService:
provider=provider, provider=provider,
provider_user_id=provider_user_id, provider_user_id=provider_user_id,
provider_email=provider_email, provider_email=provider_email,
access_token_encrypted=token.get("access_token"), refresh_token_encrypted=token.get("refresh_token"), token_expires_at=datetime.now(UTC) access_token_encrypted=token.get("access_token"),
refresh_token_encrypted=token.get("refresh_token"),
token_expires_at=datetime.now(UTC)
+ timedelta(seconds=token.get("expires_in", 3600)) + timedelta(seconds=token.get("expires_in", 3600))
if token.get("expires_in") if token.get("expires_in")
else None, else None,
@@ -644,7 +648,9 @@ class OAuthService:
provider=provider, provider=provider,
provider_user_id=provider_user_id, provider_user_id=provider_user_id,
provider_email=email, provider_email=email,
access_token_encrypted=token.get("access_token"), refresh_token_encrypted=token.get("refresh_token"), token_expires_at=datetime.now(UTC) access_token_encrypted=token.get("access_token"),
refresh_token_encrypted=token.get("refresh_token"),
token_expires_at=datetime.now(UTC)
+ timedelta(seconds=token.get("expires_in", 3600)) + timedelta(seconds=token.get("expires_in", 3600))
if token.get("expires_in") if token.get("expires_in")
else None, else None,

View File

@@ -91,9 +91,7 @@ def spawn_agent(
Returns: Returns:
dict with status, agent_type_id, and project_id dict with status, agent_type_id, and project_id
""" """
logger.info( logger.info(f"Spawning agent of type {agent_type_id} for project {project_id}")
f"Spawning agent of type {agent_type_id} for project {project_id}"
)
# TODO: Implement agent spawning # TODO: Implement agent spawning
# This will involve: # This will involve:
@@ -132,9 +130,7 @@ def terminate_agent(
Returns: Returns:
dict with status and agent_instance_id dict with status and agent_instance_id
""" """
logger.info( logger.info(f"Terminating agent instance {agent_instance_id} with reason: {reason}")
f"Terminating agent instance {agent_instance_id} with reason: {reason}"
)
# TODO: Implement agent termination # TODO: Implement agent termination
# This will involve: # This will involve:

View File

@@ -86,9 +86,7 @@ def commit_changes(
Returns: Returns:
dict with status and project_id dict with status and project_id
""" """
logger.info( logger.info(f"Committing changes for project {project_id}: {message}")
f"Committing changes for project {project_id}: {message}"
)
# TODO: Implement commit operation # TODO: Implement commit operation
# This will involve: # This will involve:
@@ -209,9 +207,7 @@ def push_changes(
Returns: Returns:
dict with status and project_id dict with status and project_id
""" """
logger.info( logger.info(f"Pushing branch {branch} for project {project_id} (force={force})")
f"Pushing branch {branch} for project {project_id} (force={force})"
)
# TODO: Implement push operation # TODO: Implement push operation
# This will involve: # This will involve:

View File

@@ -140,9 +140,7 @@ def sync_project_issues(
Returns: Returns:
dict with status and project_id dict with status and project_id
""" """
logger.info( logger.info(f"Syncing issues for project {project_id} (full={full})")
f"Syncing issues for project {project_id} (full={full})"
)
# TODO: Implement project-specific sync # TODO: Implement project-specific sync
# This will involve: # This will involve:
@@ -180,9 +178,7 @@ def push_issue_to_external(
Returns: Returns:
dict with status, issue_id, and operation dict with status, issue_id, and operation
""" """
logger.info( logger.info(f"Pushing {operation} for issue {issue_id} in project {project_id}")
f"Pushing {operation} for issue {issue_id} in project {project_id}"
)
# TODO: Implement outbound sync # TODO: Implement outbound sync
# This will involve: # This will involve:

View File

@@ -72,9 +72,7 @@ def execute_workflow_step(
Returns: Returns:
dict with status, workflow_id, and transition dict with status, workflow_id, and transition
""" """
logger.info( logger.info(f"Executing transition '{transition}' for workflow {workflow_id}")
f"Executing transition '{transition}' for workflow {workflow_id}"
)
# TODO: Implement workflow transition # TODO: Implement workflow transition
# This will involve: # This will involve:
@@ -196,9 +194,7 @@ def start_story_workflow(
Returns: Returns:
dict with status and story_id dict with status and story_id
""" """
logger.info( logger.info(f"Starting story workflow for story {story_id} in project {project_id}")
f"Starting story workflow for story {story_id} in project {project_id}"
)
# TODO: Implement story workflow initialization # TODO: Implement story workflow initialization
# This will involve: # This will involve:

View File

@@ -0,0 +1,39 @@
# tests/api/dependencies/test_event_bus.py
"""Tests for the event_bus dependency."""
from unittest.mock import AsyncMock, patch
import pytest
from app.api.dependencies.event_bus import get_event_bus
from app.services.event_bus import EventBus
@pytest.mark.asyncio
class TestGetEventBusDependency:
"""Tests for the get_event_bus FastAPI dependency."""
async def test_get_event_bus_returns_event_bus(self):
"""Test that get_event_bus returns an EventBus instance."""
mock_event_bus = AsyncMock(spec=EventBus)
with patch(
"app.api.dependencies.event_bus._get_connected_event_bus",
return_value=mock_event_bus,
):
result = await get_event_bus()
assert result is mock_event_bus
async def test_get_event_bus_calls_get_connected_event_bus(self):
"""Test that get_event_bus calls the underlying function."""
mock_event_bus = AsyncMock(spec=EventBus)
mock_get_connected = AsyncMock(return_value=mock_event_bus)
with patch(
"app.api.dependencies.event_bus._get_connected_event_bus",
mock_get_connected,
):
await get_event_bus()
mock_get_connected.assert_called_once()

View File

@@ -299,9 +299,7 @@ class TestListAgentTypes:
class TestGetAgentType: class TestGetAgentType:
"""Tests for GET /api/v1/agent-types/{agent_type_id} endpoint.""" """Tests for GET /api/v1/agent-types/{agent_type_id} endpoint."""
async def test_get_agent_type_success( async def test_get_agent_type_success(self, client, user_token, test_agent_type):
self, client, user_token, test_agent_type
):
"""Test successful retrieval of agent type by ID.""" """Test successful retrieval of agent type by ID."""
agent_type_id = test_agent_type["id"] agent_type_id = test_agent_type["id"]
@@ -383,7 +381,9 @@ class TestGetAgentTypeBySlug:
assert data["errors"][0]["code"] == "SYS_002" # NOT_FOUND assert data["errors"][0]["code"] == "SYS_002" # NOT_FOUND
assert "non-existent-slug" in data["errors"][0]["message"] assert "non-existent-slug" in data["errors"][0]["message"]
async def test_get_agent_type_by_slug_unauthenticated(self, client, test_agent_type): async def test_get_agent_type_by_slug_unauthenticated(
self, client, test_agent_type
):
"""Test that unauthenticated users cannot get agent types by slug.""" """Test that unauthenticated users cannot get agent types by slug."""
slug = test_agent_type["slug"] slug = test_agent_type["slug"]
@@ -671,9 +671,7 @@ class TestAgentTypeModelParams:
assert data["tool_permissions"]["read_files"] is True assert data["tool_permissions"]["read_files"] is True
assert data["tool_permissions"]["execute_code"] is False assert data["tool_permissions"]["execute_code"] is False
async def test_update_model_params( async def test_update_model_params(self, client, superuser_token, test_agent_type):
self, client, superuser_token, test_agent_type
):
"""Test updating model parameters.""" """Test updating model parameters."""
agent_type_id = test_agent_type["id"] agent_type_id = test_agent_type["id"]
@@ -697,9 +695,7 @@ class TestAgentTypeModelParams:
class TestAgentTypeInstanceCount: class TestAgentTypeInstanceCount:
"""Tests for instance count tracking.""" """Tests for instance count tracking."""
async def test_new_agent_type_has_zero_instances( async def test_new_agent_type_has_zero_instances(self, client, superuser_token):
self, client, superuser_token
):
"""Test that newly created agent types have zero instances.""" """Test that newly created agent types have zero instances."""
unique_slug = f"zero-instances-{uuid.uuid4().hex[:8]}" unique_slug = f"zero-instances-{uuid.uuid4().hex[:8]}"
response = await client.post( response = await client.post(

View File

@@ -122,9 +122,7 @@ class TestSpawnAgent:
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_spawn_agent_nonexistent_type( async def test_spawn_agent_nonexistent_type(self, client, user_token, test_project):
self, client, user_token, test_project
):
"""Test spawning agent with nonexistent agent type.""" """Test spawning agent with nonexistent agent type."""
project_id = test_project["id"] project_id = test_project["id"]
fake_type_id = str(uuid.uuid4()) fake_type_id = str(uuid.uuid4())
@@ -376,9 +374,7 @@ class TestUpdateAgent:
class TestAgentLifecycle: class TestAgentLifecycle:
"""Tests for agent lifecycle management endpoints.""" """Tests for agent lifecycle management endpoints."""
async def test_pause_agent( async def test_pause_agent(self, client, user_token, test_project, test_agent_type):
self, client, user_token, test_project, test_agent_type
):
"""Test pausing an agent.""" """Test pausing an agent."""
project_id = test_project["id"] project_id = test_project["id"]
agent_type_id = test_agent_type["id"] agent_type_id = test_agent_type["id"]
@@ -617,3 +613,364 @@ class TestAgentAuthorization:
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
class TestSpawnAgentEdgeCases:
"""Tests for agent spawn edge cases."""
async def test_spawn_agent_with_inactive_agent_type(
self, client, user_token, superuser_token, test_project
):
"""Test spawning agent with an inactive agent type fails."""
project_id = test_project["id"]
# Create an inactive agent type
unique_slug = f"inactive-agent-type-{uuid.uuid4().hex[:8]}"
create_response = await client.post(
"/api/v1/agent-types",
json={
"name": "Inactive Agent Type",
"slug": unique_slug,
"expertise": ["testing"],
"primary_model": "claude-3-opus",
"personality_prompt": "Test inactive agent.",
"description": "An inactive agent type for testing",
"is_active": False,
},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_response.status_code == status.HTTP_201_CREATED
inactive_type_id = create_response.json()["id"]
# Try to spawn agent with inactive type
response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": inactive_type_id,
"name": "Agent With Inactive Type",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Error response uses standardized format with "errors" list
data = response.json()
assert "errors" in data
assert any("inactive" in err["message"].lower() for err in data["errors"])
@pytest.mark.asyncio
class TestAgentWrongProject:
"""Tests for agent operations when agent belongs to different project."""
@pytest_asyncio.fixture
async def two_projects_with_agent(
self, client, user_token, superuser_token, test_agent_type
):
"""Create two projects and an agent in project1."""
# Create project1
resp1 = await client.post(
"/api/v1/projects",
json={
"name": "Project One",
"slug": f"project-one-{uuid.uuid4().hex[:8]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project1 = resp1.json()
# Create project2
resp2 = await client.post(
"/api/v1/projects",
json={
"name": "Project Two",
"slug": f"project-two-{uuid.uuid4().hex[:8]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = resp2.json()
# Create agent in project1
agent_resp = await client.post(
f"/api/v1/projects/{project1['id']}/agents",
json={
"project_id": project1["id"],
"agent_type_id": test_agent_type["id"],
"name": "Project1 Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent = agent_resp.json()
return {"project1": project1, "project2": project2, "agent": agent}
async def test_get_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test getting an agent via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.get(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_update_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test updating an agent via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.patch(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}",
json={"current_task": "Test task"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_pause_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test pausing an agent via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.post(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_resume_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test resuming an agent via wrong project returns 404."""
data = two_projects_with_agent
project1_id = data["project1"]["id"]
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
# First pause the agent using correct project
await client.post(
f"/api/v1/projects/{project1_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to resume via wrong project
response = await client.post(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_terminate_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test terminating an agent via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.delete(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_get_agent_metrics_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test getting agent metrics via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.get(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}/metrics",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestAgentStatusTransitions:
"""Tests for invalid agent status transitions."""
async def test_terminate_already_terminated_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test terminating an already terminated agent fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Double Terminate Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Terminate once
first_terminate = await client.delete(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_terminate.status_code == status.HTTP_200_OK
# Try to terminate again
response = await client.delete(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
data = response.json()
assert "errors" in data
assert any("terminated" in err["message"].lower() for err in data["errors"])
async def test_resume_idle_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test resuming an agent that is not paused fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent (starts in idle state)
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Resume Idle Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Try to resume without pausing first
response = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
# Should fail since agent is not paused
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_pause_already_paused_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test pausing an already paused agent fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Double Pause Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Pause once
first_pause = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_pause.status_code == status.HTTP_200_OK
# Try to pause again
response = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_pause_terminated_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test pausing a terminated agent fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Pause Terminated Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Terminate agent
await client.delete(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to pause terminated agent
response = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_resume_terminated_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test resuming a terminated agent fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Resume Terminated Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Terminate agent
await client.delete(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to resume terminated agent
response = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

View File

@@ -74,7 +74,9 @@ async def terminated_agent(client, user_token, test_project, test_agent):
f"/api/v1/projects/{test_project['id']}/agents/{test_agent['id']}", f"/api/v1/projects/{test_project['id']}/agents/{test_agent['id']}",
headers={"Authorization": f"Bearer {user_token}"}, headers={"Authorization": f"Bearer {user_token}"},
) )
assert response.status_code == status.HTTP_200_OK, f"Failed to terminate: {response.json()}" assert response.status_code == status.HTTP_200_OK, (
f"Failed to terminate: {response.json()}"
)
# Return agent info with terminated status # Return agent info with terminated status
return {**test_agent, "status": "terminated"} return {**test_agent, "status": "terminated"}
@@ -432,7 +434,7 @@ class TestProjectArchivingEdgeCases:
agent_id = test_agent["id"] agent_id = test_agent["id"]
# Set agent to working status # Set agent to working status
status_response = await client.patch( await client.patch(
f"/api/v1/projects/{project_id}/agents/{agent_id}/status", f"/api/v1/projects/{project_id}/agents/{agent_id}/status",
json={"status": "working", "current_task": "Processing something"}, json={"status": "working", "current_task": "Processing something"},
headers={"Authorization": f"Bearer {user_token}"}, headers={"Authorization": f"Bearer {user_token}"},
@@ -475,7 +477,6 @@ class TestConcurrencyEdgeCases:
If two requests try to start sprints simultaneously, only one should succeed. If two requests try to start sprints simultaneously, only one should succeed.
""" """
import asyncio
from datetime import date, timedelta from datetime import date, timedelta
project_id = test_project["id"] project_id = test_project["id"]
@@ -509,7 +510,9 @@ class TestConcurrencyEdgeCases:
) )
# Exactly one should succeed # Exactly one should succeed
successes = sum(1 for r in [start1, start2] if r.status_code == status.HTTP_200_OK) successes = sum(
1 for r in [start1, start2] if r.status_code == status.HTTP_200_OK
)
failures = sum(1 for r in [start1, start2] if r.status_code in [400, 409, 422]) failures = sum(1 for r in [start1, start2] if r.status_code in [400, 409, 422])
assert successes == 1, f"Expected exactly 1 success, got {successes}" assert successes == 1, f"Expected exactly 1 success, got {successes}"
@@ -593,9 +596,7 @@ class TestDataIntegrityEdgeCases:
assert update_response.status_code == status.HTTP_404_NOT_FOUND assert update_response.status_code == status.HTTP_404_NOT_FOUND
async def test_assign_issue_to_other_projects_sprint( async def test_assign_issue_to_other_projects_sprint(self, client, user_token):
self, client, user_token
):
""" """
IDOR Test: Try to assign an issue to a sprint from a different project. IDOR Test: Try to assign an issue to a sprint from a different project.
""" """
@@ -624,6 +625,7 @@ class TestDataIntegrityEdgeCases:
# Create a sprint in project 2 # Create a sprint in project 2
from datetime import date, timedelta from datetime import date, timedelta
sprint_response = await client.post( sprint_response = await client.post(
f"/api/v1/projects/{p2['id']}/sprints", f"/api/v1/projects/{p2['id']}/sprints",
json={ json={
@@ -662,7 +664,9 @@ class TestDataIntegrityEdgeCases:
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND, status.HTTP_404_NOT_FOUND,
status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_422_UNPROCESSABLE_ENTITY,
], f"IDOR BUG: Assigned issue to another project's sprint! Status: {update_response.status_code}" ], (
f"IDOR BUG: Assigned issue to another project's sprint! Status: {update_response.status_code}"
)
async def test_assign_issue_to_other_projects_agent( async def test_assign_issue_to_other_projects_agent(
self, client, user_token, superuser_token self, client, user_token, superuser_token
@@ -744,7 +748,9 @@ class TestDataIntegrityEdgeCases:
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND, status.HTTP_404_NOT_FOUND,
status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_422_UNPROCESSABLE_ENTITY,
], f"IDOR BUG: Assigned issue to another project's agent! Status: {update_response.status_code}" ], (
f"IDOR BUG: Assigned issue to another project's agent! Status: {update_response.status_code}"
)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1084,6 +1090,6 @@ class TestArchiveProjectCleanup:
# BUG CHECK: Sprint should be cancelled after project archive # BUG CHECK: Sprint should be cancelled after project archive
if sprint_data.get("status") == "active": if sprint_data.get("status") == "active":
pytest.fail( pytest.fail(
f"BUG: Sprint status is still 'active' after project archive. " "BUG: Sprint status is still 'active' after project archive. "
f"Expected 'cancelled'. Archive should cancel active sprints." "Expected 'cancelled'. Archive should cancel active sprints."
) )

View File

@@ -108,7 +108,9 @@ class TestCreateIssue:
assert "urgent" in data["labels"] assert "urgent" in data["labels"]
assert "frontend" in data["labels"] assert "frontend" in data["labels"]
async def test_create_issue_with_story_points(self, client, user_token, test_project): async def test_create_issue_with_story_points(
self, client, user_token, test_project
):
"""Test creating issue with story points.""" """Test creating issue with story points."""
project_id = test_project["id"] project_id = test_project["id"]
@@ -237,7 +239,9 @@ class TestListIssues:
assert len(data["data"]) == 1 assert len(data["data"]) == 1
assert data["data"][0]["status"] == "open" assert data["data"][0]["status"] == "open"
async def test_list_issues_filter_by_priority(self, client, user_token, test_project): async def test_list_issues_filter_by_priority(
self, client, user_token, test_project
):
"""Test filtering issues by priority.""" """Test filtering issues by priority."""
project_id = test_project["id"] project_id = test_project["id"]
@@ -703,7 +707,9 @@ class TestIssueAssignment:
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_assign_issue_clears_assignment(self, client, user_token, test_project): async def test_assign_issue_clears_assignment(
self, client, user_token, test_project
):
"""Test that assigning to null clears both assignments.""" """Test that assigning to null clears both assignments."""
project_id = test_project["id"] project_id = test_project["id"]
@@ -890,7 +896,9 @@ class TestIssueCrossProjectValidation:
class TestIssueValidation: class TestIssueValidation:
"""Tests for issue validation during create/update.""" """Tests for issue validation during create/update."""
async def test_create_issue_invalid_priority(self, client, user_token, test_project): async def test_create_issue_invalid_priority(
self, client, user_token, test_project
):
"""Test creating issue with invalid priority.""" """Test creating issue with invalid priority."""
project_id = test_project["id"] project_id = test_project["id"]
@@ -922,7 +930,9 @@ class TestIssueValidation:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_update_issue_invalid_priority(self, client, user_token, test_project): async def test_update_issue_invalid_priority(
self, client, user_token, test_project
):
"""Test updating issue with invalid priority.""" """Test updating issue with invalid priority."""
project_id = test_project["id"] project_id = test_project["id"]

View File

@@ -243,14 +243,22 @@ class TestListProjects:
# Create active project # Create active project
await client.post( await client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": "Active Project", "slug": "active-project", "status": "active"}, json={
"name": "Active Project",
"slug": "active-project",
"status": "active",
},
headers={"Authorization": f"Bearer {user_token}"}, headers={"Authorization": f"Bearer {user_token}"},
) )
# Create paused project # Create paused project
await client.post( await client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": "Paused Project", "slug": "paused-project", "status": "paused"}, json={
"name": "Paused Project",
"slug": "paused-project",
"status": "paused",
},
headers={"Authorization": f"Bearer {user_token}"}, headers={"Authorization": f"Bearer {user_token}"},
) )

View File

@@ -233,7 +233,9 @@ class TestListSprints:
assert len(data["data"]) == 3 assert len(data["data"]) == 3
assert data["pagination"]["total"] == 3 assert data["pagination"]["total"] == 3
async def test_list_sprints_filter_by_status(self, client, user_token, test_project): async def test_list_sprints_filter_by_status(
self, client, user_token, test_project
):
"""Test filtering sprints by status.""" """Test filtering sprints by status."""
project_id = test_project["id"] project_id = test_project["id"]
start_date = date.today() start_date = date.today()
@@ -582,7 +584,9 @@ class TestSprintLifecycle:
class TestDeleteSprint: class TestDeleteSprint:
"""Tests for DELETE /api/v1/projects/{project_id}/sprints/{sprint_id} endpoint.""" """Tests for DELETE /api/v1/projects/{project_id}/sprints/{sprint_id} endpoint."""
async def test_delete_planned_sprint_success(self, client, user_token, test_project): async def test_delete_planned_sprint_success(
self, client, user_token, test_project
):
"""Test deleting a planned sprint.""" """Test deleting a planned sprint."""
project_id = test_project["id"] project_id = test_project["id"]
start_date = date.today() start_date = date.today()
@@ -1119,3 +1123,419 @@ class TestSprintCrossProjectValidation:
) )
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestSprintStatusTransitions:
"""Tests for invalid sprint status transitions."""
async def test_cancel_completed_sprint(self, client, user_token, test_project):
"""Test that cancelling a completed sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create, start, and complete sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Complete Then Cancel",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to cancel completed sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_cancel_already_cancelled_sprint(
self, client, user_token, test_project
):
"""Test that cancelling an already cancelled sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and cancel sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Double Cancel Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Cancel once
first_cancel = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_cancel.status_code == status.HTTP_200_OK
# Try to cancel again
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_complete_already_completed_sprint(
self, client, user_token, test_project
):
"""Test that completing an already completed sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create, start, and complete sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Double Complete Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Complete once
first_complete = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_complete.status_code == status.HTTP_200_OK
# Try to complete again
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_complete_cancelled_sprint(self, client, user_token, test_project):
"""Test that completing a cancelled sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and cancel sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Complete Cancelled Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to complete cancelled sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_start_cancelled_sprint(self, client, user_token, test_project):
"""Test that starting a cancelled sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and cancel sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Start Cancelled Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to start cancelled sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_start_completed_sprint(self, client, user_token, test_project):
"""Test that starting a completed sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create, start, and complete sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Start Completed Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to start completed sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
class TestSprintWrongProject:
"""Tests for sprint operations when sprint belongs to different project."""
async def test_complete_sprint_wrong_project(self, client, user_token):
"""Test completing a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Complete P1", "slug": f"complete-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Complete P2", "slug": f"complete-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and start sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Complete Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
await client.post(
f"/api/v1/projects/{project1_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to complete via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_cancel_sprint_wrong_project(self, client, user_token):
"""Test cancelling a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Cancel P1", "slug": f"cancel-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Cancel P2", "slug": f"cancel-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Cancel Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to cancel via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_delete_sprint_wrong_project(self, client, user_token):
"""Test deleting a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Delete P1", "slug": f"delete-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Delete P2", "slug": f"delete-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Delete Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to delete via wrong project
response = await client.delete(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_add_issue_to_sprint_wrong_project(self, client, user_token):
"""Test adding issue to sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={
"name": "Add Issue P1",
"slug": f"add-issue-p1-{uuid.uuid4().hex[:6]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={
"name": "Add Issue P2",
"slug": f"add-issue-p2-{uuid.uuid4().hex[:6]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Add Issue Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Create issue in project1
issue_response = await client.post(
f"/api/v1/projects/{project1_id}/issues",
json={
"project_id": project1_id,
"title": "Test Issue",
},
headers={"Authorization": f"Bearer {user_token}"},
)
issue_id = issue_response.json()["id"]
# Try to add issue via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/issues",
params={"issue_id": issue_id},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND

View File

@@ -274,7 +274,11 @@ class TestSSEEndpointStream:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_events_with_events( async def test_stream_events_with_events(
self, client_with_mock_bus, user_token_with_mock_bus, mock_event_bus, test_project_for_events self,
client_with_mock_bus,
user_token_with_mock_bus,
mock_event_bus,
test_project_for_events,
): ):
"""Test that SSE endpoint yields events.""" """Test that SSE endpoint yields events."""
project_id = test_project_for_events.id project_id = test_project_for_events.id
@@ -361,7 +365,11 @@ class TestTestEventEndpoint:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_test_event_success( async def test_send_test_event_success(
self, client_with_mock_bus, user_token_with_mock_bus, mock_event_bus, test_project_for_events self,
client_with_mock_bus,
user_token_with_mock_bus,
mock_event_bus,
test_project_for_events,
): ):
"""Test sending a test event.""" """Test sending a test event."""
project_id = test_project_for_events.id project_id = test_project_for_events.id

View File

@@ -437,3 +437,197 @@ class TestOAuthProviderEndpoints:
) )
# Missing client_id returns 401 (invalid_client) # Missing client_id returns 401 (invalid_client)
assert response.status_code == 401 assert response.status_code == 401
class TestOAuthProviderAdminEndpoints:
"""Tests for OAuth provider admin endpoints."""
@pytest.mark.asyncio
async def test_list_clients_admin_only(self, client, user_token):
"""Test that listing clients requires superuser."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get(
"/api/v1/oauth/provider/clients",
headers={"Authorization": f"Bearer {user_token}"},
)
# Regular user should be forbidden
assert response.status_code == 403
@pytest.mark.asyncio
async def test_list_clients_success(self, client, superuser_token):
"""Test listing OAuth clients as superuser."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get(
"/api/v1/oauth/provider/clients",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_delete_client_not_found(self, client, superuser_token):
"""Test deleting non-existent OAuth client."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.delete(
"/api/v1/oauth/provider/clients/non_existent_client_id",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_client_success(self, client, superuser_token, async_test_db):
"""Test successfully deleting an OAuth client."""
_test_engine, AsyncTestingSessionLocal = async_test_db
from app.crud.oauth import oauth_client
from app.schemas.oauth import OAuthClientCreate
# Create a test client to delete
async with AsyncTestingSessionLocal() as session:
client_data = OAuthClientCreate(
client_name="Delete Test Client",
redirect_uris=["http://localhost:3000/callback"],
allowed_scopes=["read:users"],
)
test_client, _ = await oauth_client.create_client(
session, obj_in=client_data
)
test_client_id = test_client.client_id
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.delete(
f"/api/v1/oauth/provider/clients/{test_client_id}",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 204
class TestOAuthProviderConsentEndpoints:
"""Tests for OAuth provider consent management endpoints."""
@pytest.mark.asyncio
async def test_list_consents_unauthenticated(self, client):
"""Test listing consents without authentication."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get("/api/v1/oauth/provider/consents")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_consents_empty(self, client, user_token):
"""Test listing consents when user has none."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get(
"/api/v1/oauth/provider/consents",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_list_consents_with_data(
self, client, user_token, async_test_user, async_test_db
):
"""Test listing consents when user has granted some."""
_test_engine, AsyncTestingSessionLocal = async_test_db
from app.crud.oauth import oauth_client
from app.models.oauth_provider_token import OAuthConsent
from app.schemas.oauth import OAuthClientCreate
# Create a test client and grant consent
async with AsyncTestingSessionLocal() as session:
client_data = OAuthClientCreate(
client_name="Consented App",
redirect_uris=["http://localhost:3000/callback"],
allowed_scopes=["read:users", "write:users"],
)
test_client, _ = await oauth_client.create_client(
session, obj_in=client_data
)
# Create consent record
consent = OAuthConsent(
user_id=async_test_user.id,
client_id=test_client.client_id,
granted_scopes="read:users write:users",
)
session.add(consent)
await session.commit()
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get(
"/api/v1/oauth/provider/consents",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["client_name"] == "Consented App"
assert "read:users" in data[0]["granted_scopes"]
@pytest.mark.asyncio
async def test_revoke_consent_not_found(self, client, user_token):
"""Test revoking consent that doesn't exist."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.delete(
"/api/v1/oauth/provider/consents/non_existent_client",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_revoke_consent_success(
self, client, user_token, async_test_user, async_test_db
):
"""Test successfully revoking consent."""
_test_engine, AsyncTestingSessionLocal = async_test_db
from app.crud.oauth import oauth_client
from app.models.oauth_provider_token import OAuthConsent
from app.schemas.oauth import OAuthClientCreate
# Create a test client and grant consent
async with AsyncTestingSessionLocal() as session:
client_data = OAuthClientCreate(
client_name="Revoke Test App",
redirect_uris=["http://localhost:3000/callback"],
allowed_scopes=["read:users"],
)
test_client, _ = await oauth_client.create_client(
session, obj_in=client_data
)
test_client_id = test_client.client_id
# Create consent record
consent = OAuthConsent(
user_id=async_test_user.id,
client_id=test_client.client_id,
granted_scopes="read:users",
)
session.add(consent)
await session.commit()
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.delete(
f"/api/v1/oauth/provider/consents/{test_client_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 204

View File

@@ -159,7 +159,9 @@ async def test_agent_type_crud(async_test_db, agent_type_create_data):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def test_agent_instance_crud(async_test_db, test_project_crud, test_agent_type_crud): async def test_agent_instance_crud(
async_test_db, test_project_crud, test_agent_type_crud
):
"""Create a test agent instance in the database for CRUD tests.""" """Create a test agent instance in the database for CRUD tests."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:

View File

@@ -203,7 +203,7 @@ class TestAgentInstanceGetByProject:
self, db_session, test_project, test_agent_instance self, db_session, test_project, test_agent_instance
): ):
"""Test getting agent instances with status filter.""" """Test getting agent instances with status filter."""
instances, total = await agent_instance.get_by_project( instances, _total = await agent_instance.get_by_project(
db_session, db_session,
project_id=test_project.id, project_id=test_project.id,
status=AgentStatus.IDLE, status=AgentStatus.IDLE,

View File

@@ -17,7 +17,9 @@ class TestAgentInstanceCreate:
"""Tests for agent instance creation.""" """Tests for agent instance creation."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_agent_instance_success(self, async_test_db, test_project_crud, test_agent_type_crud): async def test_create_agent_instance_success(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test successfully creating an agent instance.""" """Test successfully creating an agent instance."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -41,7 +43,9 @@ class TestAgentInstanceCreate:
assert result.short_term_memory == {"context": "initial"} assert result.short_term_memory == {"context": "initial"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_agent_instance_minimal(self, async_test_db, test_project_crud, test_agent_type_crud): async def test_create_agent_instance_minimal(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test creating agent instance with minimal fields.""" """Test creating agent instance with minimal fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -62,12 +66,16 @@ class TestAgentInstanceRead:
"""Tests for agent instance read operations.""" """Tests for agent instance read operations."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_agent_instance_by_id(self, async_test_db, test_agent_instance_crud): async def test_get_agent_instance_by_id(
self, async_test_db, test_agent_instance_crud
):
"""Test getting agent instance by ID.""" """Test getting agent instance by ID."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id)) result = await agent_instance_crud.get(
session, id=str(test_agent_instance_crud.id)
)
assert result is not None assert result is not None
assert result.id == test_agent_instance_crud.id assert result.id == test_agent_instance_crud.id
@@ -102,33 +110,48 @@ class TestAgentInstanceUpdate:
"""Tests for agent instance update operations.""" """Tests for agent instance update operations."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_agent_instance_status(self, async_test_db, test_agent_instance_crud): async def test_update_agent_instance_status(
self, async_test_db, test_agent_instance_crud
):
"""Test updating agent instance status.""" """Test updating agent instance status."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
instance = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id)) instance = await agent_instance_crud.get(
session, id=str(test_agent_instance_crud.id)
)
update_data = AgentInstanceUpdate( update_data = AgentInstanceUpdate(
status=AgentStatus.WORKING, status=AgentStatus.WORKING,
current_task="Processing feature request", current_task="Processing feature request",
) )
result = await agent_instance_crud.update(session, db_obj=instance, obj_in=update_data) result = await agent_instance_crud.update(
session, db_obj=instance, obj_in=update_data
)
assert result.status == AgentStatus.WORKING assert result.status == AgentStatus.WORKING
assert result.current_task == "Processing feature request" assert result.current_task == "Processing feature request"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_agent_instance_memory(self, async_test_db, test_agent_instance_crud): async def test_update_agent_instance_memory(
self, async_test_db, test_agent_instance_crud
):
"""Test updating agent instance short-term memory.""" """Test updating agent instance short-term memory."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
instance = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id)) instance = await agent_instance_crud.get(
session, id=str(test_agent_instance_crud.id)
)
new_memory = {"conversation": ["msg1", "msg2"], "decisions": {"key": "value"}} new_memory = {
"conversation": ["msg1", "msg2"],
"decisions": {"key": "value"},
}
update_data = AgentInstanceUpdate(short_term_memory=new_memory) update_data = AgentInstanceUpdate(short_term_memory=new_memory)
result = await agent_instance_crud.update(session, db_obj=instance, obj_in=update_data) result = await agent_instance_crud.update(
session, db_obj=instance, obj_in=update_data
)
assert result.short_term_memory == new_memory assert result.short_term_memory == new_memory
@@ -172,7 +195,9 @@ class TestAgentInstanceTerminate:
"""Tests for agent instance termination.""" """Tests for agent instance termination."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_terminate_agent_instance(self, async_test_db, test_project_crud, test_agent_type_crud): async def test_terminate_agent_instance(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test terminating an agent instance.""" """Test terminating an agent instance."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -189,7 +214,9 @@ class TestAgentInstanceTerminate:
# Terminate # Terminate
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.terminate(session, instance_id=instance_id) result = await agent_instance_crud.terminate(
session, instance_id=instance_id
)
assert result is not None assert result is not None
assert result.status == AgentStatus.TERMINATED assert result.status == AgentStatus.TERMINATED
@@ -203,7 +230,9 @@ class TestAgentInstanceTerminate:
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.terminate(session, instance_id=uuid.uuid4()) result = await agent_instance_crud.terminate(
session, instance_id=uuid.uuid4()
)
assert result is None assert result is None
@@ -211,7 +240,9 @@ class TestAgentInstanceMetrics:
"""Tests for agent instance metrics operations.""" """Tests for agent instance metrics operations."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_record_task_completion(self, async_test_db, test_agent_instance_crud): async def test_record_task_completion(
self, async_test_db, test_agent_instance_crud
):
"""Test recording task completion with metrics.""" """Test recording task completion with metrics."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -230,7 +261,9 @@ class TestAgentInstanceMetrics:
assert result.last_activity_at is not None assert result.last_activity_at is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_record_multiple_task_completions(self, async_test_db, test_project_crud, test_agent_type_crud): async def test_record_multiple_task_completions(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test recording multiple task completions accumulates metrics.""" """Test recording multiple task completions accumulates metrics."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -267,7 +300,9 @@ class TestAgentInstanceMetrics:
assert result.cost_incurred == Decimal("0.0300") assert result.cost_incurred == Decimal("0.0300")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_project_metrics(self, async_test_db, test_project_crud, test_agent_instance_crud): async def test_get_project_metrics(
self, async_test_db, test_project_crud, test_agent_instance_crud
):
"""Test getting aggregated metrics for a project.""" """Test getting aggregated metrics for a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -290,7 +325,9 @@ class TestAgentInstanceByProject:
"""Tests for getting instances by project.""" """Tests for getting instances by project."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_agent_instance_crud): async def test_get_by_project(
self, async_test_db, test_project_crud, test_agent_instance_crud
):
"""Test getting instances by project.""" """Test getting instances by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -304,7 +341,9 @@ class TestAgentInstanceByProject:
assert all(i.project_id == test_project_crud.id for i in instances) assert all(i.project_id == test_project_crud.id for i in instances)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_by_project_with_status(self, async_test_db, test_project_crud, test_agent_type_crud): async def test_get_by_project_with_status(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test getting instances by project filtered by status.""" """Test getting instances by project filtered by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -340,7 +379,9 @@ class TestAgentInstanceByAgentType:
"""Tests for getting instances by agent type.""" """Tests for getting instances by agent type."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_by_agent_type(self, async_test_db, test_agent_type_crud, test_agent_instance_crud): async def test_get_by_agent_type(
self, async_test_db, test_agent_type_crud, test_agent_instance_crud
):
"""Test getting instances by agent type.""" """Test getting instances by agent type."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -358,7 +399,9 @@ class TestBulkTerminate:
"""Tests for bulk termination of instances.""" """Tests for bulk termination of instances."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bulk_terminate_by_project(self, async_test_db, test_project_crud, test_agent_type_crud): async def test_bulk_terminate_by_project(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test bulk terminating all instances in a project.""" """Test bulk terminating all instances in a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db

View File

@@ -9,8 +9,7 @@ import pytest_asyncio
from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.exc import IntegrityError, OperationalError
from app.crud.syndarix.agent_type import agent_type from app.crud.syndarix.agent_type import agent_type
from app.models.syndarix import AgentInstance, AgentType, Project from app.models.syndarix import AgentType
from app.models.syndarix.enums import AgentStatus, ProjectStatus
from app.schemas.syndarix import AgentTypeCreate from app.schemas.syndarix import AgentTypeCreate
@@ -95,7 +94,9 @@ class TestAgentTypeCreate:
# Mock IntegrityError with slug in the message # Mock IntegrityError with slug in the message
mock_orig = MagicMock() mock_orig = MagicMock()
mock_orig.__str__ = lambda self: "duplicate key value violates unique constraint on slug" mock_orig.__str__ = (
lambda self: "duplicate key value violates unique constraint on slug"
)
with patch.object( with patch.object(
db_session, db_session,
@@ -152,13 +153,13 @@ class TestAgentTypeGetMultiWithFilters:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_multi_with_filters_success(self, db_session, test_agent_type): async def test_get_multi_with_filters_success(self, db_session, test_agent_type):
"""Test successfully getting agent types with filters.""" """Test successfully getting agent types with filters."""
results, total = await agent_type.get_multi_with_filters(db_session) _results, total = await agent_type.get_multi_with_filters(db_session)
assert total >= 1 assert total >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_multi_with_filters_sort_asc(self, db_session, test_agent_type): async def test_get_multi_with_filters_sort_asc(self, db_session, test_agent_type):
"""Test getting agent types with ascending sort order.""" """Test getting agent types with ascending sort order."""
results, total = await agent_type.get_multi_with_filters( _results, total = await agent_type.get_multi_with_filters(
db_session, db_session,
sort_by="created_at", sort_by="created_at",
sort_order="asc", sort_order="asc",
@@ -256,14 +257,18 @@ class TestAgentTypeGetByExpertise:
"""Tests for getting agent types by expertise.""" """Tests for getting agent types by expertise."""
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skip(reason="Uses PostgreSQL JSONB contains operator, not available in SQLite") @pytest.mark.skip(
reason="Uses PostgreSQL JSONB contains operator, not available in SQLite"
)
async def test_get_by_expertise_success(self, db_session, test_agent_type): async def test_get_by_expertise_success(self, db_session, test_agent_type):
"""Test successfully getting agent types by expertise.""" """Test successfully getting agent types by expertise."""
results = await agent_type.get_by_expertise(db_session, expertise="python") results = await agent_type.get_by_expertise(db_session, expertise="python")
assert len(results) >= 1 assert len(results) >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skip(reason="Uses PostgreSQL JSONB contains operator, not available in SQLite") @pytest.mark.skip(
reason="Uses PostgreSQL JSONB contains operator, not available in SQLite"
)
async def test_get_by_expertise_db_error(self, db_session): async def test_get_by_expertise_db_error(self, db_session):
"""Test getting agent types by expertise when DB error occurs.""" """Test getting agent types by expertise when DB error occurs."""
with patch.object( with patch.object(

View File

@@ -42,7 +42,9 @@ class TestAgentTypeCreate:
assert result.is_active is True assert result.is_active is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_agent_type_duplicate_slug_fails(self, async_test_db, test_agent_type_crud): async def test_create_agent_type_duplicate_slug_fails(
self, async_test_db, test_agent_type_crud
):
"""Test creating agent type with duplicate slug raises ValueError.""" """Test creating agent type with duplicate slug raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -109,7 +111,9 @@ class TestAgentTypeRead:
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get_by_slug(session, slug=test_agent_type_crud.slug) result = await agent_type_crud.get_by_slug(
session, slug=test_agent_type_crud.slug
)
assert result is not None assert result is not None
assert result.slug == test_agent_type_crud.slug assert result.slug == test_agent_type_crud.slug
@@ -120,7 +124,9 @@ class TestAgentTypeRead:
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get_by_slug(session, slug="non-existent-agent") result = await agent_type_crud.get_by_slug(
session, slug="non-existent-agent"
)
assert result is None assert result is None
@@ -128,48 +134,66 @@ class TestAgentTypeUpdate:
"""Tests for agent type update operations.""" """Tests for agent type update operations."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_agent_type_basic_fields(self, async_test_db, test_agent_type_crud): async def test_update_agent_type_basic_fields(
self, async_test_db, test_agent_type_crud
):
"""Test updating basic agent type fields.""" """Test updating basic agent type fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id)) agent_type = await agent_type_crud.get(
session, id=str(test_agent_type_crud.id)
)
update_data = AgentTypeUpdate( update_data = AgentTypeUpdate(
name="Updated Agent Name", name="Updated Agent Name",
description="Updated description", description="Updated description",
) )
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data) result = await agent_type_crud.update(
session, db_obj=agent_type, obj_in=update_data
)
assert result.name == "Updated Agent Name" assert result.name == "Updated Agent Name"
assert result.description == "Updated description" assert result.description == "Updated description"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_agent_type_expertise(self, async_test_db, test_agent_type_crud): async def test_update_agent_type_expertise(
self, async_test_db, test_agent_type_crud
):
"""Test updating agent type expertise.""" """Test updating agent type expertise."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id)) agent_type = await agent_type_crud.get(
session, id=str(test_agent_type_crud.id)
)
update_data = AgentTypeUpdate( update_data = AgentTypeUpdate(
expertise=["new-skill", "another-skill"], expertise=["new-skill", "another-skill"],
) )
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data) result = await agent_type_crud.update(
session, db_obj=agent_type, obj_in=update_data
)
assert "new-skill" in result.expertise assert "new-skill" in result.expertise
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_agent_type_model_params(self, async_test_db, test_agent_type_crud): async def test_update_agent_type_model_params(
self, async_test_db, test_agent_type_crud
):
"""Test updating agent type model parameters.""" """Test updating agent type model parameters."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id)) agent_type = await agent_type_crud.get(
session, id=str(test_agent_type_crud.id)
)
new_params = {"temperature": 0.9, "max_tokens": 8192} new_params = {"temperature": 0.9, "max_tokens": 8192}
update_data = AgentTypeUpdate(model_params=new_params) update_data = AgentTypeUpdate(model_params=new_params)
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data) result = await agent_type_crud.update(
session, db_obj=agent_type, obj_in=update_data
)
assert result.model_params == new_params assert result.model_params == new_params
@@ -311,7 +335,9 @@ class TestAgentTypeSpecialMethods:
# Deactivate # Deactivate
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.deactivate(session, agent_type_id=agent_type_id) result = await agent_type_crud.deactivate(
session, agent_type_id=agent_type_id
)
assert result is not None assert result is not None
assert result.is_active is False assert result.is_active is False
@@ -322,11 +348,15 @@ class TestAgentTypeSpecialMethods:
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.deactivate(session, agent_type_id=uuid.uuid4()) result = await agent_type_crud.deactivate(
session, agent_type_id=uuid.uuid4()
)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_with_instance_count(self, async_test_db, test_agent_type_crud, test_agent_instance_crud): async def test_get_with_instance_count(
self, async_test_db, test_agent_type_crud, test_agent_instance_crud
):
"""Test getting agent type with instance count.""" """Test getting agent type with instance count."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db

View File

@@ -3,13 +3,13 @@
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.exc import IntegrityError, OperationalError
from app.crud.syndarix.issue import CRUDIssue, issue from app.crud.syndarix.issue import issue
from app.models.syndarix import Issue, Project, Sprint from app.models.syndarix import Issue, Project, Sprint
from app.models.syndarix.enums import ( from app.models.syndarix.enums import (
IssuePriority, IssuePriority,
@@ -18,7 +18,7 @@ from app.models.syndarix.enums import (
SprintStatus, SprintStatus,
SyncStatus, SyncStatus,
) )
from app.schemas.syndarix import IssueCreate, IssueUpdate from app.schemas.syndarix import IssueCreate
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -48,6 +48,7 @@ async def test_project(db_session):
async def test_sprint(db_session, test_project): async def test_sprint(db_session, test_project):
"""Create a test sprint.""" """Create a test sprint."""
from datetime import date from datetime import date
sprint = Sprint( sprint = Sprint(
id=uuid.uuid4(), id=uuid.uuid4(),
project_id=test_project.id, project_id=test_project.id,
@@ -203,7 +204,7 @@ class TestIssueGetByProject:
await db_session.commit() await db_session.commit()
# Test status filter # Test status filter
issues, total = await issue.get_by_project( issues, _total = await issue.get_by_project(
db_session, db_session,
project_id=test_project.id, project_id=test_project.id,
status=IssueStatus.IN_PROGRESS, status=IssueStatus.IN_PROGRESS,
@@ -212,7 +213,7 @@ class TestIssueGetByProject:
assert issues[0].status == IssueStatus.IN_PROGRESS assert issues[0].status == IssueStatus.IN_PROGRESS
# Test priority filter # Test priority filter
issues, total = await issue.get_by_project( issues, _total = await issue.get_by_project(
db_session, db_session,
project_id=test_project.id, project_id=test_project.id,
priority=IssuePriority.HIGH, priority=IssuePriority.HIGH,
@@ -221,12 +222,14 @@ class TestIssueGetByProject:
assert issues[0].priority == IssuePriority.HIGH assert issues[0].priority == IssuePriority.HIGH
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skip(reason="Labels filter uses PostgreSQL @> operator, not available in SQLite") @pytest.mark.skip(
reason="Labels filter uses PostgreSQL @> operator, not available in SQLite"
)
async def test_get_by_project_with_labels_filter( async def test_get_by_project_with_labels_filter(
self, db_session, test_project, test_issue self, db_session, test_project, test_issue
): ):
"""Test getting issues filtered by labels.""" """Test getting issues filtered by labels."""
issues, total = await issue.get_by_project( issues, _total = await issue.get_by_project(
db_session, db_session,
project_id=test_project.id, project_id=test_project.id,
labels=["bug"], labels=["bug"],
@@ -249,7 +252,7 @@ class TestIssueGetByProject:
db_session.add(issue2) db_session.add(issue2)
await db_session.commit() await db_session.commit()
issues, total = await issue.get_by_project( issues, _total = await issue.get_by_project(
db_session, db_session,
project_id=test_project.id, project_id=test_project.id,
sort_by="created_at", sort_by="created_at",
@@ -257,8 +260,16 @@ class TestIssueGetByProject:
) )
assert len(issues) == 2 assert len(issues) == 2
# Compare without timezone info since DB may strip it # Compare without timezone info since DB may strip it
first_time = issues[0].created_at.replace(tzinfo=None) if issues[0].created_at.tzinfo else issues[0].created_at first_time = (
second_time = issues[1].created_at.replace(tzinfo=None) if issues[1].created_at.tzinfo else issues[1].created_at issues[0].created_at.replace(tzinfo=None)
if issues[0].created_at.tzinfo
else issues[0].created_at
)
second_time = (
issues[1].created_at.replace(tzinfo=None)
if issues[1].created_at.tzinfo
else issues[1].created_at
)
assert first_time <= second_time assert first_time <= second_time
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -561,9 +572,7 @@ class TestIssueExternalTracker:
assert len(issues) >= 1 assert len(issues) >= 1
# Test with project filter # Test with project filter
issues = await issue.get_pending_sync( issues = await issue.get_pending_sync(db_session, project_id=test_project.id)
db_session, project_id=test_project.id
)
assert len(issues) >= 1 assert len(issues) >= 1
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -42,7 +42,9 @@ class TestIssueCreate:
assert result.story_points == 5 assert result.story_points == 5
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_issue_with_external_tracker(self, async_test_db, test_project_crud): async def test_create_issue_with_external_tracker(
self, async_test_db, test_project_crud
):
"""Test creating issue with external tracker info.""" """Test creating issue with external tracker info."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -182,7 +184,9 @@ class TestIssueAssignment:
"""Tests for issue assignment operations.""" """Tests for issue assignment operations."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_assign_to_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud): async def test_assign_to_agent(
self, async_test_db, test_issue_crud, test_agent_instance_crud
):
"""Test assigning issue to an agent.""" """Test assigning issue to an agent."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -198,7 +202,9 @@ class TestIssueAssignment:
assert result.human_assignee is None assert result.human_assignee is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unassign_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud): async def test_unassign_agent(
self, async_test_db, test_issue_crud, test_agent_instance_crud
):
"""Test unassigning agent from issue.""" """Test unassigning agent from issue."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -237,7 +243,9 @@ class TestIssueAssignment:
assert result.assigned_agent_id is None assert result.assigned_agent_id is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_assign_to_human_clears_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud): async def test_assign_to_human_clears_agent(
self, async_test_db, test_issue_crud, test_agent_instance_crud
):
"""Test assigning to human clears agent assignment.""" """Test assigning to human clears agent assignment."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -304,7 +312,9 @@ class TestIssueByProject:
"""Tests for getting issues by project.""" """Tests for getting issues by project."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_issue_crud): async def test_get_by_project(
self, async_test_db, test_project_crud, test_issue_crud
):
"""Test getting issues by project.""" """Test getting issues by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -397,7 +407,9 @@ class TestIssueBySprint:
"""Tests for getting issues by sprint.""" """Tests for getting issues by sprint."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_by_sprint(self, async_test_db, test_project_crud, test_sprint_crud): async def test_get_by_sprint(
self, async_test_db, test_project_crud, test_sprint_crud
):
"""Test getting issues by sprint.""" """Test getting issues by sprint."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -533,7 +545,11 @@ class TestIssueStats:
# Create issues with various statuses and priorities # Create issues with various statuses and priorities
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
for status in [IssueStatus.OPEN, IssueStatus.IN_PROGRESS, IssueStatus.CLOSED]: for status in [
IssueStatus.OPEN,
IssueStatus.IN_PROGRESS,
IssueStatus.CLOSED,
]:
issue_data = IssueCreate( issue_data = IssueCreate(
project_id=test_project_crud.id, project_id=test_project_crud.id,
title=f"Stats Issue {status.value}", title=f"Stats Issue {status.value}",

View File

@@ -10,7 +10,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError
from app.crud.syndarix.project import project from app.crud.syndarix.project import project
from app.models.syndarix import Project from app.models.syndarix import Project
from app.models.syndarix.enums import AutonomyLevel, ProjectStatus from app.models.syndarix.enums import ProjectStatus
from app.schemas.syndarix import ProjectCreate from app.schemas.syndarix import ProjectCreate
@@ -88,7 +88,9 @@ class TestProjectCreate:
# Mock IntegrityError with slug in the message # Mock IntegrityError with slug in the message
mock_orig = MagicMock() mock_orig = MagicMock()
mock_orig.__str__ = lambda self: "duplicate key value violates unique constraint on slug" mock_orig.__str__ = (
lambda self: "duplicate key value violates unique constraint on slug"
)
with patch.object( with patch.object(
db_session, db_session,
@@ -141,7 +143,7 @@ class TestProjectGetMultiWithFilters:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_multi_with_filters_success(self, db_session, test_project): async def test_get_multi_with_filters_success(self, db_session, test_project):
"""Test successfully getting projects with filters.""" """Test successfully getting projects with filters."""
results, total = await project.get_multi_with_filters(db_session) _results, total = await project.get_multi_with_filters(db_session)
assert total >= 1 assert total >= 1
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -162,17 +164,13 @@ class TestProjectGetWithCounts:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_with_counts_not_found(self, db_session): async def test_get_with_counts_not_found(self, db_session):
"""Test getting non-existent project with counts.""" """Test getting non-existent project with counts."""
result = await project.get_with_counts( result = await project.get_with_counts(db_session, project_id=uuid.uuid4())
db_session, project_id=uuid.uuid4()
)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_with_counts_success(self, db_session, test_project): async def test_get_with_counts_success(self, db_session, test_project):
"""Test successfully getting project with counts.""" """Test successfully getting project with counts."""
result = await project.get_with_counts( result = await project.get_with_counts(db_session, project_id=test_project.id)
db_session, project_id=test_project.id
)
assert result is not None assert result is not None
assert result["project"].id == test_project.id assert result["project"].id == test_project.id
assert result["agent_count"] == 0 assert result["agent_count"] == 0
@@ -187,9 +185,7 @@ class TestProjectGetWithCounts:
side_effect=OperationalError("Connection lost", {}, Exception()), side_effect=OperationalError("Connection lost", {}, Exception()),
): ):
with pytest.raises(OperationalError): with pytest.raises(OperationalError):
await project.get_with_counts( await project.get_with_counts(db_session, project_id=test_project.id)
db_session, project_id=test_project.id
)
class TestProjectGetMultiWithCounts: class TestProjectGetMultiWithCounts:
@@ -233,9 +229,7 @@ class TestProjectGetByOwner:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_projects_by_owner_empty(self, db_session): async def test_get_projects_by_owner_empty(self, db_session):
"""Test getting projects by owner when none exist.""" """Test getting projects by owner when none exist."""
results = await project.get_projects_by_owner( results = await project.get_projects_by_owner(db_session, owner_id=uuid.uuid4())
db_session, owner_id=uuid.uuid4()
)
assert results == [] assert results == []
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -247,9 +241,7 @@ class TestProjectGetByOwner:
side_effect=OperationalError("Connection lost", {}, Exception()), side_effect=OperationalError("Connection lost", {}, Exception()),
): ):
with pytest.raises(OperationalError): with pytest.raises(OperationalError):
await project.get_projects_by_owner( await project.get_projects_by_owner(db_session, owner_id=uuid.uuid4())
db_session, owner_id=uuid.uuid4()
)
class TestProjectArchive: class TestProjectArchive:
@@ -264,9 +256,7 @@ class TestProjectArchive:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_archive_project_success(self, db_session, test_project): async def test_archive_project_success(self, db_session, test_project):
"""Test successfully archiving project.""" """Test successfully archiving project."""
result = await project.archive_project( result = await project.archive_project(db_session, project_id=test_project.id)
db_session, project_id=test_project.id
)
assert result is not None assert result is not None
assert result.status == ProjectStatus.ARCHIVED assert result.status == ProjectStatus.ARCHIVED
@@ -279,6 +269,4 @@ class TestProjectArchive:
side_effect=OperationalError("Connection lost", {}, Exception()), side_effect=OperationalError("Connection lost", {}, Exception()),
): ):
with pytest.raises(OperationalError): with pytest.raises(OperationalError):
await project.archive_project( await project.archive_project(db_session, project_id=test_project.id)
db_session, project_id=test_project.id
)

View File

@@ -42,7 +42,9 @@ class TestProjectCreate:
assert result.owner_id == test_owner_crud.id assert result.owner_id == test_owner_crud.id
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_project_duplicate_slug_fails(self, async_test_db, test_project_crud): async def test_create_project_duplicate_slug_fails(
self, async_test_db, test_project_crud
):
"""Test creating project with duplicate slug raises ValueError.""" """Test creating project with duplicate slug raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -106,7 +108,9 @@ class TestProjectRead:
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await project_crud.get_by_slug(session, slug=test_project_crud.slug) result = await project_crud.get_by_slug(
session, slug=test_project_crud.slug
)
assert result is not None assert result is not None
assert result.slug == test_project_crud.slug assert result.slug == test_project_crud.slug
@@ -136,7 +140,9 @@ class TestProjectUpdate:
name="Updated Project Name", name="Updated Project Name",
description="Updated description", description="Updated description",
) )
result = await project_crud.update(session, db_obj=project, obj_in=update_data) result = await project_crud.update(
session, db_obj=project, obj_in=update_data
)
assert result.name == "Updated Project Name" assert result.name == "Updated Project Name"
assert result.description == "Updated description" assert result.description == "Updated description"
@@ -150,12 +156,16 @@ class TestProjectUpdate:
project = await project_crud.get(session, id=str(test_project_crud.id)) project = await project_crud.get(session, id=str(test_project_crud.id))
update_data = ProjectUpdate(status=ProjectStatus.PAUSED) update_data = ProjectUpdate(status=ProjectStatus.PAUSED)
result = await project_crud.update(session, db_obj=project, obj_in=update_data) result = await project_crud.update(
session, db_obj=project, obj_in=update_data
)
assert result.status == ProjectStatus.PAUSED assert result.status == ProjectStatus.PAUSED
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_project_autonomy_level(self, async_test_db, test_project_crud): async def test_update_project_autonomy_level(
self, async_test_db, test_project_crud
):
"""Test updating project autonomy level.""" """Test updating project autonomy level."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -163,7 +173,9 @@ class TestProjectUpdate:
project = await project_crud.get(session, id=str(test_project_crud.id)) project = await project_crud.get(session, id=str(test_project_crud.id))
update_data = ProjectUpdate(autonomy_level=AutonomyLevel.AUTONOMOUS) update_data = ProjectUpdate(autonomy_level=AutonomyLevel.AUTONOMOUS)
result = await project_crud.update(session, db_obj=project, obj_in=update_data) result = await project_crud.update(
session, db_obj=project, obj_in=update_data
)
assert result.autonomy_level == AutonomyLevel.AUTONOMOUS assert result.autonomy_level == AutonomyLevel.AUTONOMOUS
@@ -175,9 +187,14 @@ class TestProjectUpdate:
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
project = await project_crud.get(session, id=str(test_project_crud.id)) project = await project_crud.get(session, id=str(test_project_crud.id))
new_settings = {"mcp_servers": ["gitea", "slack"], "webhook_url": "https://example.com"} new_settings = {
"mcp_servers": ["gitea", "slack"],
"webhook_url": "https://example.com",
}
update_data = ProjectUpdate(settings=new_settings) update_data = ProjectUpdate(settings=new_settings)
result = await project_crud.update(session, db_obj=project, obj_in=update_data) result = await project_crud.update(
session, db_obj=project, obj_in=update_data
)
assert result.settings == new_settings assert result.settings == new_settings
@@ -273,7 +290,9 @@ class TestProjectFilters:
assert any(p.name == "Searchable Project" for p in projects) assert any(p.name == "Searchable Project" for p in projects)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_multi_with_filters_owner(self, async_test_db, test_owner_crud, test_project_crud): async def test_get_multi_with_filters_owner(
self, async_test_db, test_owner_crud, test_project_crud
):
"""Test filtering projects by owner.""" """Test filtering projects by owner."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -287,7 +306,9 @@ class TestProjectFilters:
assert all(p.owner_id == test_owner_crud.id for p in projects) assert all(p.owner_id == test_owner_crud.id for p in projects)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_multi_with_filters_pagination(self, async_test_db, test_owner_crud): async def test_get_multi_with_filters_pagination(
self, async_test_db, test_owner_crud
):
"""Test pagination of project results.""" """Test pagination of project results."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -348,7 +369,9 @@ class TestProjectSpecialMethods:
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await project_crud.archive_project(session, project_id=test_project_crud.id) result = await project_crud.archive_project(
session, project_id=test_project_crud.id
)
assert result is not None assert result is not None
assert result.status == ProjectStatus.ARCHIVED assert result.status == ProjectStatus.ARCHIVED
@@ -359,11 +382,15 @@ class TestProjectSpecialMethods:
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session: async with AsyncTestingSessionLocal() as session:
result = await project_crud.archive_project(session, project_id=uuid.uuid4()) result = await project_crud.archive_project(
session, project_id=uuid.uuid4()
)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_projects_by_owner(self, async_test_db, test_owner_crud, test_project_crud): async def test_get_projects_by_owner(
self, async_test_db, test_owner_crud, test_project_crud
):
"""Test getting all projects by owner.""" """Test getting all projects by owner."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -377,7 +404,9 @@ class TestProjectSpecialMethods:
assert all(p.owner_id == test_owner_crud.id for p in projects) assert all(p.owner_id == test_owner_crud.id for p in projects)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_projects_by_owner_with_status(self, async_test_db, test_owner_crud): async def test_get_projects_by_owner_with_status(
self, async_test_db, test_owner_crud
):
"""Test getting projects by owner filtered by status.""" """Test getting projects by owner filtered by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db

View File

@@ -9,7 +9,7 @@ import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.exc import IntegrityError, OperationalError
from app.crud.syndarix.sprint import CRUDSprint, sprint from app.crud.syndarix.sprint import sprint
from app.models.syndarix import Issue, Project, Sprint from app.models.syndarix import Issue, Project, Sprint
from app.models.syndarix.enums import ( from app.models.syndarix.enums import (
IssueStatus, IssueStatus,
@@ -174,7 +174,7 @@ class TestSprintGetByProject:
self, db_session, test_project, test_sprint self, db_session, test_project, test_sprint
): ):
"""Test getting sprints with status filter.""" """Test getting sprints with status filter."""
sprints, total = await sprint.get_by_project( sprints, _total = await sprint.get_by_project(
db_session, db_session,
project_id=test_project.id, project_id=test_project.id,
status=SprintStatus.PLANNED, status=SprintStatus.PLANNED,
@@ -478,7 +478,7 @@ class TestSprintWithIssueCounts:
db_session.add_all([issue1, issue2]) db_session.add_all([issue1, issue2])
await db_session.commit() await db_session.commit()
results, total = await sprint.get_sprints_with_issue_counts( results, _total = await sprint.get_sprints_with_issue_counts(
db_session, project_id=test_project.id db_session, project_id=test_project.id
) )
assert len(results) == 1 assert len(results) == 1

View File

@@ -121,7 +121,9 @@ class TestSprintUpdate:
name="Updated Sprint Name", name="Updated Sprint Name",
goal="Updated goal", goal="Updated goal",
) )
result = await sprint_crud.update(session, db_obj=sprint, obj_in=update_data) result = await sprint_crud.update(
session, db_obj=sprint, obj_in=update_data
)
assert result.name == "Updated Sprint Name" assert result.name == "Updated Sprint Name"
assert result.goal == "Updated goal" assert result.goal == "Updated goal"
@@ -139,7 +141,9 @@ class TestSprintUpdate:
start_date=today + timedelta(days=1), start_date=today + timedelta(days=1),
end_date=today + timedelta(days=21), end_date=today + timedelta(days=21),
) )
result = await sprint_crud.update(session, db_obj=sprint, obj_in=update_data) result = await sprint_crud.update(
session, db_obj=sprint, obj_in=update_data
)
assert result.start_date == today + timedelta(days=1) assert result.start_date == today + timedelta(days=1)
assert result.end_date == today + timedelta(days=21) assert result.end_date == today + timedelta(days=21)
@@ -163,7 +167,9 @@ class TestSprintLifecycle:
assert result.status == SprintStatus.ACTIVE assert result.status == SprintStatus.ACTIVE
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_sprint_with_custom_date(self, async_test_db, test_project_crud): async def test_start_sprint_with_custom_date(
self, async_test_db, test_project_crud
):
"""Test starting sprint with custom start date.""" """Test starting sprint with custom start date."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -195,7 +201,9 @@ class TestSprintLifecycle:
assert result.start_date == new_start assert result.start_date == new_start
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_sprint_already_active_fails(self, async_test_db, test_project_crud): async def test_start_sprint_already_active_fails(
self, async_test_db, test_project_crud
):
"""Test starting an already active sprint raises ValueError.""" """Test starting an already active sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -250,7 +258,9 @@ class TestSprintLifecycle:
assert result.status == SprintStatus.COMPLETED assert result.status == SprintStatus.COMPLETED
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_complete_planned_sprint_fails(self, async_test_db, test_project_crud): async def test_complete_planned_sprint_fails(
self, async_test_db, test_project_crud
):
"""Test completing a planned sprint raises ValueError.""" """Test completing a planned sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -300,7 +310,9 @@ class TestSprintLifecycle:
assert result.status == SprintStatus.CANCELLED assert result.status == SprintStatus.CANCELLED
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_completed_sprint_fails(self, async_test_db, test_project_crud): async def test_cancel_completed_sprint_fails(
self, async_test_db, test_project_crud
):
"""Test cancelling a completed sprint raises ValueError.""" """Test cancelling a completed sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -329,7 +341,9 @@ class TestSprintByProject:
"""Tests for getting sprints by project.""" """Tests for getting sprints by project."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_sprint_crud): async def test_get_by_project(
self, async_test_db, test_project_crud, test_sprint_crud
):
"""Test getting sprints by project.""" """Test getting sprints by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
@@ -506,7 +520,9 @@ class TestSprintWithIssueCounts:
"""Tests for getting sprints with issue counts.""" """Tests for getting sprints with issue counts."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_sprints_with_issue_counts(self, async_test_db, test_project_crud, test_sprint_crud): async def test_get_sprints_with_issue_counts(
self, async_test_db, test_project_crud, test_sprint_crud
):
"""Test getting sprints with issue counts.""" """Test getting sprints with issue counts."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db

View File

@@ -12,7 +12,7 @@ from sqlalchemy.exc import DataError, IntegrityError, OperationalError
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.crud.user import user as user_crud from app.crud.user import user as user_crud
from app.schemas.users import UserCreate, UserUpdate from app.schemas.users import UserCreate
class TestCRUDBaseGet: class TestCRUDBaseGet:

View File

@@ -48,7 +48,9 @@ class TestAgentInstanceModel:
db_session.add(instance) db_session.add(instance)
db_session.commit() db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(project_id=project.id).first() retrieved = (
db_session.query(AgentInstance).filter_by(project_id=project.id).first()
)
assert retrieved is not None assert retrieved is not None
assert retrieved.agent_type_id == agent_type.id assert retrieved.agent_type_id == agent_type.id
@@ -92,7 +94,10 @@ class TestAgentInstanceModel:
name="Bob", name="Bob",
status=AgentStatus.WORKING, status=AgentStatus.WORKING,
current_task="Implementing user authentication", current_task="Implementing user authentication",
short_term_memory={"context": "Working on auth", "recent_files": ["auth.py"]}, short_term_memory={
"context": "Working on auth",
"recent_files": ["auth.py"],
},
long_term_memory_ref="project-123/agent-456", long_term_memory_ref="project-123/agent-456",
session_id="session-abc-123", session_id="session-abc-123",
last_activity_at=now, last_activity_at=now,
@@ -107,7 +112,10 @@ class TestAgentInstanceModel:
assert retrieved.status == AgentStatus.WORKING assert retrieved.status == AgentStatus.WORKING
assert retrieved.current_task == "Implementing user authentication" assert retrieved.current_task == "Implementing user authentication"
assert retrieved.short_term_memory == {"context": "Working on auth", "recent_files": ["auth.py"]} assert retrieved.short_term_memory == {
"context": "Working on auth",
"recent_files": ["auth.py"],
}
assert retrieved.long_term_memory_ref == "project-123/agent-456" assert retrieved.long_term_memory_ref == "project-123/agent-456"
assert retrieved.session_id == "session-abc-123" assert retrieved.session_id == "session-abc-123"
assert retrieved.tasks_completed == 5 assert retrieved.tasks_completed == 5
@@ -116,7 +124,9 @@ class TestAgentInstanceModel:
def test_agent_instance_timestamps(self, db_session): def test_agent_instance_timestamps(self, db_session):
"""Test that timestamps are automatically set.""" """Test that timestamps are automatically set."""
project = Project(id=uuid.uuid4(), name="Timestamp Project", slug="timestamp-project-ai") project = Project(
id=uuid.uuid4(), name="Timestamp Project", slug="timestamp-project-ai"
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Timestamp Agent", name="Timestamp Agent",
@@ -176,7 +186,9 @@ class TestAgentInstanceStatus:
def test_all_agent_statuses(self, db_session): def test_all_agent_statuses(self, db_session):
"""Test that all agent statuses can be stored.""" """Test that all agent statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Status Project", slug="status-project-ai") project = Project(
id=uuid.uuid4(), name="Status Project", slug="status-project-ai"
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Status Agent", name="Status Agent",
@@ -199,12 +211,18 @@ class TestAgentInstanceStatus:
db_session.add(instance) db_session.add(instance)
db_session.commit() db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first() retrieved = (
db_session.query(AgentInstance).filter_by(id=instance.id).first()
)
assert retrieved.status == status assert retrieved.status == status
def test_status_update(self, db_session): def test_status_update(self, db_session):
"""Test updating agent instance status.""" """Test updating agent instance status."""
project = Project(id=uuid.uuid4(), name="Update Status Project", slug="update-status-project-ai") project = Project(
id=uuid.uuid4(),
name="Update Status Project",
slug="update-status-project-ai",
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Update Status Agent", name="Update Status Agent",
@@ -237,7 +255,9 @@ class TestAgentInstanceStatus:
def test_terminate_agent_instance(self, db_session): def test_terminate_agent_instance(self, db_session):
"""Test terminating an agent instance.""" """Test terminating an agent instance."""
project = Project(id=uuid.uuid4(), name="Terminate Project", slug="terminate-project-ai") project = Project(
id=uuid.uuid4(), name="Terminate Project", slug="terminate-project-ai"
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Terminate Agent", name="Terminate Agent",
@@ -281,7 +301,9 @@ class TestAgentInstanceMetrics:
def test_increment_metrics(self, db_session): def test_increment_metrics(self, db_session):
"""Test incrementing usage metrics.""" """Test incrementing usage metrics."""
project = Project(id=uuid.uuid4(), name="Metrics Project", slug="metrics-project-ai") project = Project(
id=uuid.uuid4(), name="Metrics Project", slug="metrics-project-ai"
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Metrics Agent", name="Metrics Agent",
@@ -326,7 +348,9 @@ class TestAgentInstanceMetrics:
def test_large_token_count(self, db_session): def test_large_token_count(self, db_session):
"""Test handling large token counts.""" """Test handling large token counts."""
project = Project(id=uuid.uuid4(), name="Large Tokens Project", slug="large-tokens-project-ai") project = Project(
id=uuid.uuid4(), name="Large Tokens Project", slug="large-tokens-project-ai"
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Large Tokens Agent", name="Large Tokens Agent",
@@ -359,7 +383,9 @@ class TestAgentInstanceShortTermMemory:
def test_store_complex_memory(self, db_session): def test_store_complex_memory(self, db_session):
"""Test storing complex short-term memory.""" """Test storing complex short-term memory."""
project = Project(id=uuid.uuid4(), name="Memory Project", slug="memory-project-ai") project = Project(
id=uuid.uuid4(), name="Memory Project", slug="memory-project-ai"
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Memory Agent", name="Memory Agent",
@@ -402,7 +428,11 @@ class TestAgentInstanceShortTermMemory:
def test_update_memory(self, db_session): def test_update_memory(self, db_session):
"""Test updating short-term memory.""" """Test updating short-term memory."""
project = Project(id=uuid.uuid4(), name="Update Memory Project", slug="update-memory-project-ai") project = Project(
id=uuid.uuid4(),
name="Update Memory Project",
slug="update-memory-project-ai",
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Update Memory Agent", name="Update Memory Agent",

View File

@@ -70,7 +70,10 @@ class TestAgentTypeModel:
assert retrieved.fallback_models == ["claude-sonnet-4-20250514", "gpt-4o"] assert retrieved.fallback_models == ["claude-sonnet-4-20250514", "gpt-4o"]
assert retrieved.model_params == {"temperature": 0.7, "max_tokens": 4096} assert retrieved.model_params == {"temperature": 0.7, "max_tokens": 4096}
assert retrieved.mcp_servers == ["gitea", "file-system", "slack"] assert retrieved.mcp_servers == ["gitea", "file-system", "slack"]
assert retrieved.tool_permissions == {"allowed": ["*"], "denied": ["dangerous_tool"]} assert retrieved.tool_permissions == {
"allowed": ["*"],
"denied": ["dangerous_tool"],
}
assert retrieved.is_active is True assert retrieved.is_active is True
def test_agent_type_unique_slug_constraint(self, db_session): def test_agent_type_unique_slug_constraint(self, db_session):
@@ -111,7 +114,9 @@ class TestAgentTypeModel:
db_session.add(agent_type) db_session.add(agent_type)
db_session.commit() db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="timestamp-agent").first() retrieved = (
db_session.query(AgentType).filter_by(slug="timestamp-agent").first()
)
assert isinstance(retrieved.created_at, datetime) assert isinstance(retrieved.created_at, datetime)
assert isinstance(retrieved.updated_at, datetime) assert isinstance(retrieved.updated_at, datetime)
@@ -252,7 +257,9 @@ class TestAgentTypeJsonFields:
db_session.add(agent_type) db_session.add(agent_type)
db_session.commit() db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="permissions-agent").first() retrieved = (
db_session.query(AgentType).filter_by(slug="permissions-agent").first()
)
assert retrieved.tool_permissions == tool_permissions assert retrieved.tool_permissions == tool_permissions
assert "file:read" in retrieved.tool_permissions["allowed"] assert "file:read" in retrieved.tool_permissions["allowed"]
assert retrieved.tool_permissions["limits"]["file:write"]["max_size_mb"] == 10 assert retrieved.tool_permissions["limits"]["file:write"]["max_size_mb"] == 10
@@ -269,7 +276,9 @@ class TestAgentTypeJsonFields:
db_session.add(agent_type) db_session.add(agent_type)
db_session.commit() db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="empty-json-agent").first() retrieved = (
db_session.query(AgentType).filter_by(slug="empty-json-agent").first()
)
assert retrieved.expertise == [] assert retrieved.expertise == []
assert retrieved.fallback_models == [] assert retrieved.fallback_models == []
assert retrieved.model_params == {} assert retrieved.model_params == {}

View File

@@ -107,7 +107,11 @@ class TestIssueModel:
def test_issue_timestamps(self, db_session): def test_issue_timestamps(self, db_session):
"""Test that timestamps are automatically set.""" """Test that timestamps are automatically set."""
project = Project(id=uuid.uuid4(), name="Timestamp Issue Project", slug="timestamp-issue-project") project = Project(
id=uuid.uuid4(),
name="Timestamp Issue Project",
slug="timestamp-issue-project",
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -124,7 +128,9 @@ class TestIssueModel:
def test_issue_string_representation(self, db_session): def test_issue_string_representation(self, db_session):
"""Test the string representation of an issue.""" """Test the string representation of an issue."""
project = Project(id=uuid.uuid4(), name="Repr Issue Project", slug="repr-issue-project") project = Project(
id=uuid.uuid4(), name="Repr Issue Project", slug="repr-issue-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -147,7 +153,9 @@ class TestIssueStatus:
def test_all_issue_statuses(self, db_session): def test_all_issue_statuses(self, db_session):
"""Test that all issue statuses can be stored.""" """Test that all issue statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Status Issue Project", slug="status-issue-project") project = Project(
id=uuid.uuid4(), name="Status Issue Project", slug="status-issue-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -170,7 +178,11 @@ class TestIssuePriority:
def test_all_issue_priorities(self, db_session): def test_all_issue_priorities(self, db_session):
"""Test that all issue priorities can be stored.""" """Test that all issue priorities can be stored."""
project = Project(id=uuid.uuid4(), name="Priority Issue Project", slug="priority-issue-project") project = Project(
id=uuid.uuid4(),
name="Priority Issue Project",
slug="priority-issue-project",
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -193,7 +205,9 @@ class TestIssueSyncStatus:
def test_all_sync_statuses(self, db_session): def test_all_sync_statuses(self, db_session):
"""Test that all sync statuses can be stored.""" """Test that all sync statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Sync Issue Project", slug="sync-issue-project") project = Project(
id=uuid.uuid4(), name="Sync Issue Project", slug="sync-issue-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -218,7 +232,9 @@ class TestIssueLabels:
def test_store_labels(self, db_session): def test_store_labels(self, db_session):
"""Test storing labels list.""" """Test storing labels list."""
project = Project(id=uuid.uuid4(), name="Labels Issue Project", slug="labels-issue-project") project = Project(
id=uuid.uuid4(), name="Labels Issue Project", slug="labels-issue-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -239,7 +255,9 @@ class TestIssueLabels:
def test_update_labels(self, db_session): def test_update_labels(self, db_session):
"""Test updating labels.""" """Test updating labels."""
project = Project(id=uuid.uuid4(), name="Update Labels Project", slug="update-labels-project") project = Project(
id=uuid.uuid4(), name="Update Labels Project", slug="update-labels-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -255,7 +273,9 @@ class TestIssueLabels:
issue.labels = ["updated", "new-label"] issue.labels = ["updated", "new-label"]
db_session.commit() db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Update Labels Issue").first() retrieved = (
db_session.query(Issue).filter_by(title="Update Labels Issue").first()
)
assert "initial" not in retrieved.labels assert "initial" not in retrieved.labels
assert "updated" in retrieved.labels assert "updated" in retrieved.labels
@@ -265,7 +285,9 @@ class TestIssueAssignment:
def test_assign_to_agent(self, db_session): def test_assign_to_agent(self, db_session):
"""Test assigning an issue to an agent.""" """Test assigning an issue to an agent."""
project = Project(id=uuid.uuid4(), name="Agent Assign Project", slug="agent-assign-project") project = Project(
id=uuid.uuid4(), name="Agent Assign Project", slug="agent-assign-project"
)
agent_type = AgentType( agent_type = AgentType(
id=uuid.uuid4(), id=uuid.uuid4(),
name="Test Agent Type", name="Test Agent Type",
@@ -295,13 +317,17 @@ class TestIssueAssignment:
db_session.add(issue) db_session.add(issue)
db_session.commit() db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Agent Assignment Issue").first() retrieved = (
db_session.query(Issue).filter_by(title="Agent Assignment Issue").first()
)
assert retrieved.assigned_agent_id == agent_instance.id assert retrieved.assigned_agent_id == agent_instance.id
assert retrieved.human_assignee is None assert retrieved.human_assignee is None
def test_assign_to_human(self, db_session): def test_assign_to_human(self, db_session):
"""Test assigning an issue to a human.""" """Test assigning an issue to a human."""
project = Project(id=uuid.uuid4(), name="Human Assign Project", slug="human-assign-project") project = Project(
id=uuid.uuid4(), name="Human Assign Project", slug="human-assign-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -314,7 +340,9 @@ class TestIssueAssignment:
db_session.add(issue) db_session.add(issue)
db_session.commit() db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Human Assignment Issue").first() retrieved = (
db_session.query(Issue).filter_by(title="Human Assignment Issue").first()
)
assert retrieved.human_assignee == "developer@example.com" assert retrieved.human_assignee == "developer@example.com"
assert retrieved.assigned_agent_id is None assert retrieved.assigned_agent_id is None
@@ -324,7 +352,9 @@ class TestIssueSprintAssociation:
def test_assign_issue_to_sprint(self, db_session): def test_assign_issue_to_sprint(self, db_session):
"""Test assigning an issue to a sprint.""" """Test assigning an issue to a sprint."""
project = Project(id=uuid.uuid4(), name="Sprint Assign Project", slug="sprint-assign-project") project = Project(
id=uuid.uuid4(), name="Sprint Assign Project", slug="sprint-assign-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -381,7 +411,9 @@ class TestIssueExternalTracker:
db_session.add(issue) db_session.add(issue)
db_session.commit() db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Gitea Synced Issue").first() retrieved = (
db_session.query(Issue).filter_by(title="Gitea Synced Issue").first()
)
assert retrieved.external_tracker_type == "gitea" assert retrieved.external_tracker_type == "gitea"
assert retrieved.external_issue_id == "abc123xyz" assert retrieved.external_issue_id == "abc123xyz"
assert retrieved.external_issue_number == 42 assert retrieved.external_issue_number == 42
@@ -405,7 +437,9 @@ class TestIssueExternalTracker:
db_session.add(issue) db_session.add(issue)
db_session.commit() db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="GitHub Synced Issue").first() retrieved = (
db_session.query(Issue).filter_by(title="GitHub Synced Issue").first()
)
assert retrieved.external_tracker_type == "github" assert retrieved.external_tracker_type == "github"
assert retrieved.external_issue_number == 100 assert retrieved.external_issue_number == 100
@@ -415,7 +449,9 @@ class TestIssueLifecycle:
def test_close_issue(self, db_session): def test_close_issue(self, db_session):
"""Test closing an issue.""" """Test closing an issue."""
project = Project(id=uuid.uuid4(), name="Close Issue Project", slug="close-issue-project") project = Project(
id=uuid.uuid4(), name="Close Issue Project", slug="close-issue-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -440,7 +476,9 @@ class TestIssueLifecycle:
def test_reopen_issue(self, db_session): def test_reopen_issue(self, db_session):
"""Test reopening a closed issue.""" """Test reopening a closed issue."""
project = Project(id=uuid.uuid4(), name="Reopen Issue Project", slug="reopen-issue-project") project = Project(
id=uuid.uuid4(), name="Reopen Issue Project", slug="reopen-issue-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()

View File

@@ -100,7 +100,9 @@ class TestProjectModel:
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
retrieved = db_session.query(Project).filter_by(slug="timestamp-project").first() retrieved = (
db_session.query(Project).filter_by(slug="timestamp-project").first()
)
assert isinstance(retrieved.created_at, datetime) assert isinstance(retrieved.created_at, datetime)
assert isinstance(retrieved.updated_at, datetime) assert isinstance(retrieved.updated_at, datetime)
@@ -177,7 +179,11 @@ class TestProjectEnums:
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
retrieved = db_session.query(Project).filter_by(slug=f"project-{level.value}").first() retrieved = (
db_session.query(Project)
.filter_by(slug=f"project-{level.value}")
.first()
)
assert retrieved.autonomy_level == level assert retrieved.autonomy_level == level
def test_all_project_statuses(self, db_session): def test_all_project_statuses(self, db_session):
@@ -192,7 +198,11 @@ class TestProjectEnums:
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
retrieved = db_session.query(Project).filter_by(slug=f"project-status-{status.value}").first() retrieved = (
db_session.query(Project)
.filter_by(slug=f"project-status-{status.value}")
.first()
)
assert retrieved.status == status assert retrieved.status == status
@@ -227,7 +237,10 @@ class TestProjectSettings:
assert retrieved.settings == complex_settings assert retrieved.settings == complex_settings
assert retrieved.settings["mcp_servers"] == ["gitea", "slack", "file-system"] assert retrieved.settings["mcp_servers"] == ["gitea", "slack", "file-system"]
assert retrieved.settings["webhook_urls"]["on_issue_created"] == "https://example.com/issue" assert (
retrieved.settings["webhook_urls"]["on_issue_created"]
== "https://example.com/issue"
)
assert "important" in retrieved.settings["tags"] assert "important" in retrieved.settings["tags"]
def test_empty_settings(self, db_session): def test_empty_settings(self, db_session):

View File

@@ -91,7 +91,11 @@ class TestSprintModel:
def test_sprint_timestamps(self, db_session): def test_sprint_timestamps(self, db_session):
"""Test that timestamps are automatically set.""" """Test that timestamps are automatically set."""
project = Project(id=uuid.uuid4(), name="Timestamp Sprint Project", slug="timestamp-sprint-project") project = Project(
id=uuid.uuid4(),
name="Timestamp Sprint Project",
slug="timestamp-sprint-project",
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -112,7 +116,9 @@ class TestSprintModel:
def test_sprint_string_representation(self, db_session): def test_sprint_string_representation(self, db_session):
"""Test the string representation of a sprint.""" """Test the string representation of a sprint."""
project = Project(id=uuid.uuid4(), name="Repr Sprint Project", slug="repr-sprint-project") project = Project(
id=uuid.uuid4(), name="Repr Sprint Project", slug="repr-sprint-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -139,7 +145,9 @@ class TestSprintStatus:
def test_all_sprint_statuses(self, db_session): def test_all_sprint_statuses(self, db_session):
"""Test that all sprint statuses can be stored.""" """Test that all sprint statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Status Sprint Project", slug="status-sprint-project") project = Project(
id=uuid.uuid4(), name="Status Sprint Project", slug="status-sprint-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -166,7 +174,9 @@ class TestSprintLifecycle:
def test_start_sprint(self, db_session): def test_start_sprint(self, db_session):
"""Test starting a planned sprint.""" """Test starting a planned sprint."""
project = Project(id=uuid.uuid4(), name="Start Sprint Project", slug="start-sprint-project") project = Project(
id=uuid.uuid4(), name="Start Sprint Project", slug="start-sprint-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -194,7 +204,11 @@ class TestSprintLifecycle:
def test_complete_sprint(self, db_session): def test_complete_sprint(self, db_session):
"""Test completing an active sprint.""" """Test completing an active sprint."""
project = Project(id=uuid.uuid4(), name="Complete Sprint Project", slug="complete-sprint-project") project = Project(
id=uuid.uuid4(),
name="Complete Sprint Project",
slug="complete-sprint-project",
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -217,13 +231,17 @@ class TestSprintLifecycle:
sprint.velocity = 18 sprint.velocity = 18
db_session.commit() db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Sprint to Complete").first() retrieved = (
db_session.query(Sprint).filter_by(name="Sprint to Complete").first()
)
assert retrieved.status == SprintStatus.COMPLETED assert retrieved.status == SprintStatus.COMPLETED
assert retrieved.velocity == 18 assert retrieved.velocity == 18
def test_cancel_sprint(self, db_session): def test_cancel_sprint(self, db_session):
"""Test cancelling a sprint.""" """Test cancelling a sprint."""
project = Project(id=uuid.uuid4(), name="Cancel Sprint Project", slug="cancel-sprint-project") project = Project(
id=uuid.uuid4(), name="Cancel Sprint Project", slug="cancel-sprint-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -254,7 +272,9 @@ class TestSprintDates:
def test_sprint_date_range(self, db_session): def test_sprint_date_range(self, db_session):
"""Test storing sprint date range.""" """Test storing sprint date range."""
project = Project(id=uuid.uuid4(), name="Date Range Project", slug="date-range-project") project = Project(
id=uuid.uuid4(), name="Date Range Project", slug="date-range-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -278,7 +298,9 @@ class TestSprintDates:
def test_one_day_sprint(self, db_session): def test_one_day_sprint(self, db_session):
"""Test creating a one-day sprint.""" """Test creating a one-day sprint."""
project = Project(id=uuid.uuid4(), name="One Day Project", slug="one-day-project") project = Project(
id=uuid.uuid4(), name="One Day Project", slug="one-day-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -299,7 +321,9 @@ class TestSprintDates:
def test_long_sprint(self, db_session): def test_long_sprint(self, db_session):
"""Test creating a long sprint (e.g., 4 weeks).""" """Test creating a long sprint (e.g., 4 weeks)."""
project = Project(id=uuid.uuid4(), name="Long Sprint Project", slug="long-sprint-project") project = Project(
id=uuid.uuid4(), name="Long Sprint Project", slug="long-sprint-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -325,7 +349,9 @@ class TestSprintPoints:
def test_sprint_with_zero_points(self, db_session): def test_sprint_with_zero_points(self, db_session):
"""Test sprint with zero planned points.""" """Test sprint with zero planned points."""
project = Project(id=uuid.uuid4(), name="Zero Points Project", slug="zero-points-project") project = Project(
id=uuid.uuid4(), name="Zero Points Project", slug="zero-points-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -343,13 +369,17 @@ class TestSprintPoints:
db_session.add(sprint) db_session.add(sprint)
db_session.commit() db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Zero Points Sprint").first() retrieved = (
db_session.query(Sprint).filter_by(name="Zero Points Sprint").first()
)
assert retrieved.planned_points == 0 assert retrieved.planned_points == 0
assert retrieved.velocity == 0 assert retrieved.velocity == 0
def test_sprint_velocity_calculation(self, db_session): def test_sprint_velocity_calculation(self, db_session):
"""Test that we can calculate velocity from points.""" """Test that we can calculate velocity from points."""
project = Project(id=uuid.uuid4(), name="Velocity Project", slug="velocity-project") project = Project(
id=uuid.uuid4(), name="Velocity Project", slug="velocity-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -376,7 +406,9 @@ class TestSprintPoints:
def test_sprint_overdelivery(self, db_session): def test_sprint_overdelivery(self, db_session):
"""Test sprint where completed > planned (stretch goals).""" """Test sprint where completed > planned (stretch goals)."""
project = Project(id=uuid.uuid4(), name="Overdelivery Project", slug="overdelivery-project") project = Project(
id=uuid.uuid4(), name="Overdelivery Project", slug="overdelivery-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -395,7 +427,9 @@ class TestSprintPoints:
db_session.add(sprint) db_session.add(sprint)
db_session.commit() db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Overdelivery Sprint").first() retrieved = (
db_session.query(Sprint).filter_by(name="Overdelivery Sprint").first()
)
assert retrieved.velocity > retrieved.planned_points assert retrieved.velocity > retrieved.planned_points
@@ -404,7 +438,9 @@ class TestSprintNumber:
def test_sequential_sprint_numbers(self, db_session): def test_sequential_sprint_numbers(self, db_session):
"""Test creating sprints with sequential numbers.""" """Test creating sprints with sequential numbers."""
project = Project(id=uuid.uuid4(), name="Sequential Project", slug="sequential-project") project = Project(
id=uuid.uuid4(), name="Sequential Project", slug="sequential-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -421,14 +457,21 @@ class TestSprintNumber:
db_session.add(sprint) db_session.add(sprint)
db_session.commit() db_session.commit()
sprints = db_session.query(Sprint).filter_by(project_id=project.id).order_by(Sprint.number).all() sprints = (
db_session.query(Sprint)
.filter_by(project_id=project.id)
.order_by(Sprint.number)
.all()
)
assert len(sprints) == 5 assert len(sprints) == 5
for i, sprint in enumerate(sprints, 1): for i, sprint in enumerate(sprints, 1):
assert sprint.number == i assert sprint.number == i
def test_large_sprint_number(self, db_session): def test_large_sprint_number(self, db_session):
"""Test sprint with large number (e.g., long-running project).""" """Test sprint with large number (e.g., long-running project)."""
project = Project(id=uuid.uuid4(), name="Large Number Project", slug="large-number-project") project = Project(
id=uuid.uuid4(), name="Large Number Project", slug="large-number-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -453,7 +496,9 @@ class TestSprintUpdate:
def test_update_sprint_goal(self, db_session): def test_update_sprint_goal(self, db_session):
"""Test updating sprint goal.""" """Test updating sprint goal."""
project = Project(id=uuid.uuid4(), name="Update Goal Project", slug="update-goal-project") project = Project(
id=uuid.uuid4(), name="Update Goal Project", slug="update-goal-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -475,14 +520,18 @@ class TestSprintUpdate:
sprint.goal = "Updated goal with more detail" sprint.goal = "Updated goal with more detail"
db_session.commit() db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Update Goal Sprint").first() retrieved = (
db_session.query(Sprint).filter_by(name="Update Goal Sprint").first()
)
assert retrieved.goal == "Updated goal with more detail" assert retrieved.goal == "Updated goal with more detail"
assert retrieved.created_at == original_created_at assert retrieved.created_at == original_created_at
assert retrieved.updated_at > original_created_at assert retrieved.updated_at > original_created_at
def test_update_sprint_dates(self, db_session): def test_update_sprint_dates(self, db_session):
"""Test updating sprint dates.""" """Test updating sprint dates."""
project = Project(id=uuid.uuid4(), name="Update Dates Project", slug="update-dates-project") project = Project(
id=uuid.uuid4(), name="Update Dates Project", slug="update-dates-project"
)
db_session.add(project) db_session.add(project)
db_session.commit() db_session.commit()
@@ -502,6 +551,8 @@ class TestSprintUpdate:
sprint.end_date = today + timedelta(days=21) sprint.end_date = today + timedelta(days=21)
db_session.commit() db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Update Dates Sprint").first() retrieved = (
db_session.query(Sprint).filter_by(name="Update Dates Sprint").first()
)
delta = retrieved.end_date - retrieved.start_date delta = retrieved.end_date - retrieved.start_date
assert delta.days == 21 assert delta.days == 21

View File

@@ -10,7 +10,6 @@ These tests verify:
""" """
class TestCeleryAppConfiguration: class TestCeleryAppConfiguration:
"""Tests for the Celery application instance configuration.""" """Tests for the Celery application instance configuration."""

View File

@@ -134,9 +134,7 @@ class TestRecordLlmUsageTask:
] ]
for model, cost in models: for model, cost in models:
result = record_llm_usage( result = record_llm_usage(agent_id, project_id, model, 1000, 500, cost)
agent_id, project_id, model, 1000, 500, cost
)
assert result["status"] == "pending" assert result["status"] == "pending"
def test_record_llm_usage_with_zero_tokens(self): def test_record_llm_usage_with_zero_tokens(self):

View File

@@ -8,7 +8,8 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useRouter } from '@/lib/i18n/routing';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AgentTypeDetail, AgentTypeForm } from '@/components/agents'; import { AgentTypeDetail, AgentTypeForm } from '@/components/agents';
import { import {

View File

@@ -8,7 +8,7 @@
'use client'; 'use client';
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/lib/i18n/routing';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AgentTypeList } from '@/components/agents'; import { AgentTypeList } from '@/components/agents';
import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes'; import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes';

View File

@@ -10,7 +10,7 @@
*/ */
import { useState, use } from 'react'; import { useState, use } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/lib/i18n/routing';
import { Plus, Upload } from 'lucide-react'; import { Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -25,7 +25,7 @@ interface ProjectIssuesPageProps {
} }
export default function ProjectIssuesPage({ params }: ProjectIssuesPageProps) { export default function ProjectIssuesPage({ params }: ProjectIssuesPageProps) {
const { locale, id: projectId } = use(params); const { id: projectId } = use(params);
const router = useRouter(); const router = useRouter();
// Filter state // Filter state
@@ -49,7 +49,7 @@ export default function ProjectIssuesPage({ params }: ProjectIssuesPageProps) {
const { data, isLoading, error } = useIssues(projectId, filters, sort); const { data, isLoading, error } = useIssues(projectId, filters, sort);
const handleIssueClick = (issueId: string) => { const handleIssueClick = (issueId: string) => {
router.push(`/${locale}/projects/${projectId}/issues/${issueId}`); router.push(`/projects/${projectId}/issues/${issueId}`);
}; };
const handleBulkChangeStatus = () => { const handleBulkChangeStatus = () => {

View File

@@ -13,17 +13,11 @@ export const metadata: Metadata = {
description: 'Create a new Syndarix project with AI-powered agents', description: 'Create a new Syndarix project with AI-powered agents',
}; };
interface NewProjectPageProps { export default function NewProjectPage() {
params: Promise<{ locale: string }>;
}
export default async function NewProjectPage({ params }: NewProjectPageProps) {
const { locale } = await params;
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<ProjectWizard locale={locale} /> <ProjectWizard />
</div> </div>
</div> </div>
); );

View File

@@ -10,7 +10,7 @@
'use client'; 'use client';
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/lib/i18n/routing';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -3,12 +3,16 @@
* Homepage / Landing Page * Homepage / Landing Page
* Main landing page for the Syndarix project * Main landing page for the Syndarix project
* Showcases features, tech stack, and provides demos for developers * Showcases features, tech stack, and provides demos for developers
*
* If user is authenticated, redirects to dashboard
*/ */
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Link } from '@/lib/i18n/routing'; import { Link, useRouter } from '@/lib/i18n/routing';
import { useAuth } from '@/lib/auth/AuthContext';
import config from '@/config/app.config';
import { Header } from '@/components/home/Header'; import { Header } from '@/components/home/Header';
import { HeroSection } from '@/components/home/HeroSection'; import { HeroSection } from '@/components/home/HeroSection';
import { ContextSection } from '@/components/home/ContextSection'; import { ContextSection } from '@/components/home/ContextSection';
@@ -24,6 +28,20 @@ import { DemoCredentialsModal } from '@/components/home/DemoCredentialsModal';
export default function Home() { export default function Home() {
const [demoModalOpen, setDemoModalOpen] = useState(false); const [demoModalOpen, setDemoModalOpen] = useState(false);
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
// Redirect authenticated users to dashboard
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push(config.routes.dashboard);
}
}, [isLoading, isAuthenticated, router]);
// Show nothing while checking auth or redirecting
if (isLoading || isAuthenticated) {
return null;
}
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">

View File

@@ -25,7 +25,11 @@ export interface DashboardQuickStatsProps {
className?: string; className?: string;
} }
export function DashboardQuickStats({ stats, isLoading = false, className }: DashboardQuickStatsProps) { export function DashboardQuickStats({
stats,
isLoading = false,
className,
}: DashboardQuickStatsProps) {
return ( return (
<div className={className}> <div className={className}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">

View File

@@ -31,8 +31,8 @@ export function EmptyState({ userName = 'there', className }: EmptyStateProps) {
<h2 className="text-2xl font-bold">Welcome to Syndarix, {userName}!</h2> <h2 className="text-2xl font-bold">Welcome to Syndarix, {userName}!</h2>
<p className="mx-auto mt-2 max-w-md text-muted-foreground"> <p className="mx-auto mt-2 max-w-md text-muted-foreground">
Get started by creating your first project. Our AI agents will help you Get started by creating your first project. Our AI agents will help you turn your ideas
turn your ideas into reality. into reality.
</p> </p>
<Button size="lg" asChild className="mt-6"> <Button size="lg" asChild className="mt-6">

View File

@@ -66,7 +66,10 @@ const typeConfig: Record<
}, },
}; };
const priorityConfig: Record<PendingApproval['priority'], { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = { const priorityConfig: Record<
PendingApproval['priority'],
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
low: { label: 'Low', variant: 'outline' }, low: { label: 'Low', variant: 'outline' },
medium: { label: 'Medium', variant: 'secondary' }, medium: { label: 'Medium', variant: 'secondary' },
high: { label: 'High', variant: 'default' }, high: { label: 'High', variant: 'default' },
@@ -105,7 +108,12 @@ function ApprovalItem({ approval, onApprove, onReject }: ApprovalItemProps) {
return ( return (
<div className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-start"> <div className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-start">
<div className={cn('flex h-10 w-10 items-center justify-center rounded-full bg-muted', config.color)}> <div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full bg-muted',
config.color
)}
>
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</div> </div>

View File

@@ -118,10 +118,7 @@ export function RecentProjects({ projects, isLoading = false, className }: Recen
{isLoading ? ( {isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => ( {[1, 2, 3, 4, 5, 6].map((i) => (
<div <div key={i} className={cn(i > 3 && 'hidden lg:block')}>
key={i}
className={cn(i > 3 && 'hidden lg:block')}
>
<ProjectCardSkeleton /> <ProjectCardSkeleton />
</div> </div>
))} ))}
@@ -138,10 +135,7 @@ export function RecentProjects({ projects, isLoading = false, className }: Recen
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{displayProjects.map((project, index) => ( {displayProjects.map((project, index) => (
<div <div key={project.id} className={cn(index >= 3 && 'hidden lg:block')}>
key={project.id}
className={cn(index >= 3 && 'hidden lg:block')}
>
<ProjectCard project={project} /> <ProjectCard project={project} />
</div> </div>
))} ))}

View File

@@ -49,10 +49,7 @@ function ComplexityIndicator({ complexity }: { complexity: 'low' | 'medium' | 'h
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className={cn( className={cn('h-1.5 w-1.5 rounded-full', i <= level ? 'bg-primary' : 'bg-muted')}
'h-1.5 w-1.5 rounded-full',
i <= level ? 'bg-primary' : 'bg-muted'
)}
/> />
))} ))}
</div> </div>
@@ -140,10 +137,7 @@ export function ProjectCard({ project, onClick, onAction, className }: ProjectCa
Archive Project Archive Project
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem className="text-destructive" onClick={() => onAction('delete')}>
className="text-destructive"
onClick={() => onAction('delete')}
>
Delete Project Delete Project
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -10,7 +10,7 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/lib/i18n/routing';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';

View File

@@ -14,14 +14,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { import { Search, Filter, LayoutGrid, List, ChevronDown, X } from 'lucide-react';
Search,
Filter,
LayoutGrid,
List,
ChevronDown,
X,
} from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';

View File

@@ -27,7 +27,10 @@ export interface ProjectsGridProps {
/** Called when a project card is clicked */ /** Called when a project card is clicked */
onProjectClick?: (project: ProjectListItem) => void; onProjectClick?: (project: ProjectListItem) => void;
/** Called when a project action is selected */ /** Called when a project action is selected */
onProjectAction?: (project: ProjectListItem, action: 'archive' | 'pause' | 'resume' | 'delete') => void; onProjectAction?: (
project: ProjectListItem,
action: 'archive' | 'pause' | 'resume' | 'delete'
) => void;
/** Whether filters are currently applied (affects empty state message) */ /** Whether filters are currently applied (affects empty state message) */
hasFilters?: boolean; hasFilters?: boolean;
/** Additional CSS classes */ /** Additional CSS classes */
@@ -67,11 +70,7 @@ function EmptyState({ hasFilters }: { hasFilters: boolean }) {
function LoadingSkeleton({ viewMode }: { viewMode: ViewMode }) { function LoadingSkeleton({ viewMode }: { viewMode: ViewMode }) {
return ( return (
<div <div
className={cn( className={cn(viewMode === 'grid' ? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3' : 'space-y-4')}
viewMode === 'grid'
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-4'
)}
> >
{[1, 2, 3, 4, 5, 6].map((i) => ( {[1, 2, 3, 4, 5, 6].map((i) => (
<ProjectCardSkeleton key={i} /> <ProjectCardSkeleton key={i} />
@@ -100,9 +99,7 @@ export function ProjectsGrid({
return ( return (
<div <div
className={cn( className={cn(
viewMode === 'grid' viewMode === 'grid' ? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3' : 'space-y-4',
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-4',
className className
)} )}
> >

View File

@@ -51,5 +51,11 @@ export type {
} from './wizard'; } from './wizard';
export type { ProjectCardProps } from './ProjectCard'; export type { ProjectCardProps } from './ProjectCard';
export type { ProjectFiltersProps, ViewMode, SortBy, SortOrder, Complexity } from './ProjectFilters'; export type {
ProjectFiltersProps,
ViewMode,
SortBy,
SortOrder,
Complexity,
} from './ProjectFilters';
export type { ProjectsGridProps } from './ProjectsGrid'; export type { ProjectsGridProps } from './ProjectsGrid';

View File

@@ -8,7 +8,7 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/lib/i18n/routing';
import { ArrowLeft, ArrowRight, Check, CheckCircle2, Loader2 } from 'lucide-react'; import { ArrowLeft, ArrowRight, Check, CheckCircle2, Loader2 } from 'lucide-react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
@@ -49,11 +49,10 @@ interface ProjectResponse {
} }
interface ProjectWizardProps { interface ProjectWizardProps {
locale: string;
className?: string; className?: string;
} }
export function ProjectWizard({ locale, className }: ProjectWizardProps) { export function ProjectWizard({ className }: ProjectWizardProps) {
const router = useRouter(); const router = useRouter();
const [isCreated, setIsCreated] = useState(false); const [isCreated, setIsCreated] = useState(false);
@@ -106,9 +105,9 @@ export function ProjectWizard({ locale, className }: ProjectWizardProps) {
const handleGoToProject = () => { const handleGoToProject = () => {
// Navigate to project dashboard - using slug from successful creation // Navigate to project dashboard - using slug from successful creation
if (createProjectMutation.data) { if (createProjectMutation.data) {
router.push(`/${locale}/projects/${createProjectMutation.data.slug}`); router.push(`/projects/${createProjectMutation.data.slug}`);
} else { } else {
router.push(`/${locale}/projects`); router.push(`/projects`);
} }
}; };

View File

@@ -63,7 +63,8 @@ const mockProjects: ProjectListItem[] = [
{ {
id: 'proj-001', id: 'proj-001',
name: 'E-Commerce Platform Redesign', name: 'E-Commerce Platform Redesign',
description: 'Complete redesign of the e-commerce platform with modern UI/UX and improved checkout flow', description:
'Complete redesign of the e-commerce platform with modern UI/UX and improved checkout flow',
status: 'active', status: 'active',
complexity: 'high', complexity: 'high',
progress: 67, progress: 67,
@@ -78,7 +79,8 @@ const mockProjects: ProjectListItem[] = [
{ {
id: 'proj-002', id: 'proj-002',
name: 'Mobile Banking App', name: 'Mobile Banking App',
description: 'Native mobile app for banking services with biometric authentication and real-time notifications', description:
'Native mobile app for banking services with biometric authentication and real-time notifications',
status: 'active', status: 'active',
complexity: 'high', complexity: 'high',
progress: 45, progress: 45,
@@ -93,7 +95,8 @@ const mockProjects: ProjectListItem[] = [
{ {
id: 'proj-003', id: 'proj-003',
name: 'Internal HR Portal', name: 'Internal HR Portal',
description: 'Employee self-service portal for HR operations including leave requests and performance reviews', description:
'Employee self-service portal for HR operations including leave requests and performance reviews',
status: 'paused', status: 'paused',
complexity: 'medium', complexity: 'medium',
progress: 23, progress: 23,
@@ -108,7 +111,8 @@ const mockProjects: ProjectListItem[] = [
{ {
id: 'proj-004', id: 'proj-004',
name: 'API Gateway Modernization', name: 'API Gateway Modernization',
description: 'Migrate legacy API gateway to cloud-native architecture with improved rate limiting and caching', description:
'Migrate legacy API gateway to cloud-native architecture with improved rate limiting and caching',
status: 'active', status: 'active',
complexity: 'high', complexity: 'high',
progress: 82, progress: 82,
@@ -123,7 +127,8 @@ const mockProjects: ProjectListItem[] = [
{ {
id: 'proj-005', id: 'proj-005',
name: 'Customer Analytics Dashboard', name: 'Customer Analytics Dashboard',
description: 'Real-time analytics dashboard for customer behavior insights with ML-powered predictions', description:
'Real-time analytics dashboard for customer behavior insights with ML-powered predictions',
status: 'completed', status: 'completed',
complexity: 'medium', complexity: 'medium',
progress: 100, progress: 100,

View File

@@ -55,6 +55,15 @@ jest.mock('@/lib/api/hooks/useAuth', () => ({
})), })),
})); }));
// Mock AuthContext - Home page uses useAuth to check if user is authenticated
jest.mock('@/lib/auth/AuthContext', () => ({
useAuth: jest.fn(() => ({
isAuthenticated: false,
isLoading: false,
user: null,
})),
}));
// Mock Theme components // Mock Theme components
jest.mock('@/components/theme', () => ({ jest.mock('@/components/theme', () => ({
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>, ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>,

View File

@@ -244,7 +244,9 @@ describe('AgentTypeForm', () => {
await user.click(screen.getByRole('tab', { name: /model/i })); await user.click(screen.getByRole('tab', { name: /model/i }));
expect(screen.getByText('Model Selection')).toBeInTheDocument(); expect(screen.getByText('Model Selection')).toBeInTheDocument();
expect(screen.getByText('Choose the AI models that power this agent type')).toBeInTheDocument(); expect(
screen.getByText('Choose the AI models that power this agent type')
).toBeInTheDocument();
expect(screen.getByLabelText(/primary model/i)).toBeInTheDocument(); expect(screen.getByLabelText(/primary model/i)).toBeInTheDocument();
expect(screen.getByLabelText(/fallover model/i)).toBeInTheDocument(); expect(screen.getByLabelText(/fallover model/i)).toBeInTheDocument();
}); });
@@ -496,7 +498,9 @@ describe('AgentTypeForm', () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<AgentTypeForm {...defaultProps} />); render(<AgentTypeForm {...defaultProps} />);
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i) as HTMLInputElement; const expertiseInput = screen.getByPlaceholderText(
/e.g., system design/i
) as HTMLInputElement;
await user.type(expertiseInput, 'new skill'); await user.type(expertiseInput, 'new skill');
await user.click(screen.getByRole('button', { name: /^add$/i })); await user.click(screen.getByRole('button', { name: /^add$/i }));
@@ -545,14 +549,14 @@ describe('AgentTypeForm', () => {
}); });
}); });
describe('Null Model Params Handling', () => { describe('Empty Model Params Handling', () => {
it('handles null model_params gracefully', () => { it('handles empty model_params gracefully', () => {
const agentTypeWithNullParams: AgentTypeResponse = { const agentTypeWithEmptyParams: AgentTypeResponse = {
...mockAgentType, ...mockAgentType,
model_params: null, model_params: {},
}; };
render(<AgentTypeForm {...defaultProps} agentType={agentTypeWithNullParams} />); render(<AgentTypeForm {...defaultProps} agentType={agentTypeWithEmptyParams} />);
// Should render without errors // Should render without errors
expect(screen.getByText('Edit Agent Type')).toBeInTheDocument(); expect(screen.getByText('Edit Agent Type')).toBeInTheDocument();

View File

@@ -248,7 +248,7 @@ describe('ErrorBoundary', () => {
describe('error without message', () => { describe('error without message', () => {
it('handles error with null message gracefully', () => { it('handles error with null message gracefully', () => {
function ThrowNullError() { function ThrowNullError(): never {
const error = new Error(); const error = new Error();
error.message = ''; error.message = '';
throw error; throw error;
@@ -338,7 +338,7 @@ describe('ErrorBoundary', () => {
describe('edge cases', () => { describe('edge cases', () => {
it('handles deeply nested errors', () => { it('handles deeply nested errors', () => {
function DeepChild() { function DeepChild(): never {
throw new Error('Deep error'); throw new Error('Deep error');
} }
@@ -377,7 +377,7 @@ describe('ErrorBoundary', () => {
}); });
it('allows nested error boundaries', () => { it('allows nested error boundaries', () => {
function InnerThrowing() { function InnerThrowing(): never {
throw new Error('Inner error'); throw new Error('Inner error');
} }

View File

@@ -113,7 +113,9 @@ describe('RecentProjects', () => {
}); });
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render(<RecentProjects projects={mockProjects} className="custom-class" />); const { container } = render(
<RecentProjects projects={mockProjects} className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class'); expect(container.firstChild).toHaveClass('custom-class');
}); });

View File

@@ -27,7 +27,14 @@ describe('WelcomeHeader', () => {
it('displays greeting with user first name', () => { it('displays greeting with user first name', () => {
mockUseAuth.mockReturnValue({ mockUseAuth.mockReturnValue({
user: { id: '1', email: 'john@example.com', first_name: 'John', is_active: true, is_superuser: false, created_at: '' }, user: {
id: '1',
email: 'john@example.com',
first_name: 'John',
is_active: true,
is_superuser: false,
created_at: '',
},
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
error: null, error: null,
@@ -44,7 +51,14 @@ describe('WelcomeHeader', () => {
it('falls back to email prefix when first_name is empty', () => { it('falls back to email prefix when first_name is empty', () => {
mockUseAuth.mockReturnValue({ mockUseAuth.mockReturnValue({
user: { id: '1', email: 'jane@example.com', first_name: '', is_active: true, is_superuser: false, created_at: '' }, user: {
id: '1',
email: 'jane@example.com',
first_name: '',
is_active: true,
is_superuser: false,
created_at: '',
},
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
error: null, error: null,
@@ -78,7 +92,14 @@ describe('WelcomeHeader', () => {
it('displays subtitle text', () => { it('displays subtitle text', () => {
mockUseAuth.mockReturnValue({ mockUseAuth.mockReturnValue({
user: { id: '1', email: 'test@example.com', first_name: 'Test', is_active: true, is_superuser: false, created_at: '' }, user: {
id: '1',
email: 'test@example.com',
first_name: 'Test',
is_active: true,
is_superuser: false,
created_at: '',
},
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
error: null, error: null,
@@ -95,7 +116,14 @@ describe('WelcomeHeader', () => {
it('displays Create Project button', () => { it('displays Create Project button', () => {
mockUseAuth.mockReturnValue({ mockUseAuth.mockReturnValue({
user: { id: '1', email: 'test@example.com', first_name: 'Test', is_active: true, is_superuser: false, created_at: '' }, user: {
id: '1',
email: 'test@example.com',
first_name: 'Test',
is_active: true,
is_superuser: false,
created_at: '',
},
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
error: null, error: null,

View File

@@ -124,7 +124,7 @@ describe('Header', () => {
render(<Header />); render(<Header />);
const homeLink = screen.getByRole('link', { name: /home/i }); const homeLink = screen.getByRole('link', { name: /home/i });
expect(homeLink).toHaveAttribute('href', '/'); expect(homeLink).toHaveAttribute('href', '/dashboard');
}); });
it('renders admin link for superusers', () => { it('renders admin link for superusers', () => {

View File

@@ -78,7 +78,7 @@ describe('ProjectCard', () => {
// Menu button should exist with sr-only text // Menu button should exist with sr-only text
const menuButtons = screen.getAllByRole('button'); const menuButtons = screen.getAllByRole('button');
const menuButton = menuButtons.find(btn => btn.querySelector('.sr-only')); const menuButton = menuButtons.find((btn) => btn.querySelector('.sr-only'));
expect(menuButton).toBeDefined(); expect(menuButton).toBeDefined();
expect(menuButton!.querySelector('.sr-only')).toHaveTextContent('Project actions'); expect(menuButton!.querySelector('.sr-only')).toHaveTextContent('Project actions');
}); });

View File

@@ -92,18 +92,8 @@ jest.mock('@/lib/hooks/useProjectEvents', () => ({
useProjectEvents: jest.fn(() => mockUseProjectEventsResult), useProjectEvents: jest.fn(() => mockUseProjectEventsResult),
})); }));
// Mock next/navigation // Import mock from next-intl/navigation mock (used by @/lib/i18n/routing)
const mockPush = jest.fn(); import { mockPush } from 'next-intl/navigation';
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
}),
}));
describe('ProjectDashboard', () => { describe('ProjectDashboard', () => {
const projectId = 'test-project-123'; const projectId = 'test-project-123';

View File

@@ -98,9 +98,7 @@ describe('ProjectsGrid', () => {
}); });
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render( const { container } = render(<ProjectsGrid projects={mockProjects} className="custom-class" />);
<ProjectsGrid projects={mockProjects} className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class'); expect(container.firstChild).toHaveClass('custom-class');
}); });

View File

@@ -77,13 +77,8 @@ jest.mock('@/components/projects/wizard/useWizardState', () => ({
})), })),
})); }));
// Mock router // Import mock from next-intl/navigation mock (used by @/lib/i18n/routing)
const mockPush = jest.fn(); import { mockPush } from 'next-intl/navigation';
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
// Mock API client // Mock API client
const mockPost = jest.fn(); const mockPost = jest.fn();
@@ -131,36 +126,36 @@ describe('ProjectWizard', () => {
describe('Rendering', () => { describe('Rendering', () => {
it('renders the step indicator', () => { it('renders the step indicator', () => {
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('step-indicator')).toBeInTheDocument(); expect(screen.getByTestId('step-indicator')).toBeInTheDocument();
}); });
it('renders BasicInfoStep on step 1', () => { it('renders BasicInfoStep on step 1', () => {
mockWizardState.step = 1; mockWizardState.step = 1;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('basic-info-step')).toBeInTheDocument(); expect(screen.getByTestId('basic-info-step')).toBeInTheDocument();
}); });
it('renders ComplexityStep on step 2', () => { it('renders ComplexityStep on step 2', () => {
mockWizardState.step = 2; mockWizardState.step = 2;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('complexity-step')).toBeInTheDocument(); expect(screen.getByTestId('complexity-step')).toBeInTheDocument();
}); });
it('renders AgentChatStep on step 5', () => { it('renders AgentChatStep on step 5', () => {
mockWizardState.step = 5; mockWizardState.step = 5;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('agent-chat-step')).toBeInTheDocument(); expect(screen.getByTestId('agent-chat-step')).toBeInTheDocument();
}); });
it('renders ReviewStep on step 6', () => { it('renders ReviewStep on step 6', () => {
mockWizardState.step = 6; mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('review-step')).toBeInTheDocument(); expect(screen.getByTestId('review-step')).toBeInTheDocument();
}); });
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render(<ProjectWizard locale="en" className="custom-class" />, { const { container } = render(<ProjectWizard className="custom-class" />, {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
expect(container.firstChild).toHaveClass('custom-class'); expect(container.firstChild).toHaveClass('custom-class');
@@ -171,7 +166,7 @@ describe('ProjectWizard', () => {
it('calls goNext when Next button is clicked', async () => { it('calls goNext when Next button is clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockWizardState.step = 1; mockWizardState.step = 1;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /next/i })); await user.click(screen.getByRole('button', { name: /next/i }));
expect(mockGoNext).toHaveBeenCalled(); expect(mockGoNext).toHaveBeenCalled();
@@ -180,7 +175,7 @@ describe('ProjectWizard', () => {
it('calls goBack when Back button is clicked', async () => { it('calls goBack when Back button is clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockWizardState.step = 2; mockWizardState.step = 2;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /back/i })); await user.click(screen.getByRole('button', { name: /back/i }));
expect(mockGoBack).toHaveBeenCalled(); expect(mockGoBack).toHaveBeenCalled();
@@ -188,21 +183,21 @@ describe('ProjectWizard', () => {
it('hides Back button on step 1', () => { it('hides Back button on step 1', () => {
mockWizardState.step = 1; mockWizardState.step = 1;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
const backButton = screen.getByRole('button', { name: /back/i }); const backButton = screen.getByRole('button', { name: /back/i });
expect(backButton).toHaveClass('invisible'); expect(backButton).toHaveClass('invisible');
}); });
it('shows Back button visible on step 2', () => { it('shows Back button visible on step 2', () => {
mockWizardState.step = 2; mockWizardState.step = 2;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
const backButton = screen.getByRole('button', { name: /back/i }); const backButton = screen.getByRole('button', { name: /back/i });
expect(backButton).not.toHaveClass('invisible'); expect(backButton).not.toHaveClass('invisible');
}); });
it('shows Create Project button on review step', () => { it('shows Create Project button on review step', () => {
mockWizardState.step = 6; mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /create project/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /create project/i })).toBeInTheDocument();
}); });
}); });
@@ -211,7 +206,7 @@ describe('ProjectWizard', () => {
it('skips client mode step in script mode', () => { it('skips client mode step in script mode', () => {
mockWizardState.step = 3; mockWizardState.step = 3;
mockWizardState.complexity = 'script'; mockWizardState.complexity = 'script';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
// ClientModeStep should not render for script mode // ClientModeStep should not render for script mode
expect(screen.queryByTestId('client-mode-step')).not.toBeInTheDocument(); expect(screen.queryByTestId('client-mode-step')).not.toBeInTheDocument();
}); });
@@ -219,14 +214,14 @@ describe('ProjectWizard', () => {
it('skips autonomy step in script mode', () => { it('skips autonomy step in script mode', () => {
mockWizardState.step = 4; mockWizardState.step = 4;
mockWizardState.complexity = 'script'; mockWizardState.complexity = 'script';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
// AutonomyStep should not render for script mode // AutonomyStep should not render for script mode
expect(screen.queryByTestId('autonomy-step')).not.toBeInTheDocument(); expect(screen.queryByTestId('autonomy-step')).not.toBeInTheDocument();
}); });
it('shows script mode indicator', () => { it('shows script mode indicator', () => {
mockWizardState.complexity = 'script'; mockWizardState.complexity = 'script';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByText(/script mode/i)).toBeInTheDocument(); expect(screen.getByText(/script mode/i)).toBeInTheDocument();
}); });
}); });
@@ -235,7 +230,7 @@ describe('ProjectWizard', () => {
it('shows success screen after creation', async () => { it('shows success screen after creation', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockWizardState.step = 6; mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i })); await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -247,7 +242,7 @@ describe('ProjectWizard', () => {
it('displays project name in success message', async () => { it('displays project name in success message', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockWizardState.step = 6; mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i })); await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -259,7 +254,7 @@ describe('ProjectWizard', () => {
it('navigates to project dashboard on success', async () => { it('navigates to project dashboard on success', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockWizardState.step = 6; mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i })); await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -270,13 +265,14 @@ describe('ProjectWizard', () => {
}); });
await user.click(screen.getByRole('button', { name: /go to project dashboard/i })); await user.click(screen.getByRole('button', { name: /go to project dashboard/i }));
expect(mockPush).toHaveBeenCalledWith('/en/projects/test-project'); // Locale-aware router adds locale prefix automatically
expect(mockPush).toHaveBeenCalledWith('/projects/test-project');
}); });
it('allows creating another project', async () => { it('allows creating another project', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockWizardState.step = 6; mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i })); await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -294,7 +290,7 @@ describe('ProjectWizard', () => {
mockPost.mockRejectedValue(new Error('Network error')); mockPost.mockRejectedValue(new Error('Network error'));
const user = userEvent.setup(); const user = userEvent.setup();
mockWizardState.step = 6; mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i })); await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -307,13 +303,13 @@ describe('ProjectWizard', () => {
describe('Button States', () => { describe('Button States', () => {
it('disables Next button when cannot proceed', () => { it('disables Next button when cannot proceed', () => {
mockWizardState.projectName = ''; mockWizardState.projectName = '';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
}); });
it('enables Next button when can proceed', () => { it('enables Next button when can proceed', () => {
mockWizardState.projectName = 'Valid Name'; mockWizardState.projectName = 'Valid Name';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled(); expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled();
}); });
@@ -323,7 +319,7 @@ describe('ProjectWizard', () => {
); );
const user = userEvent.setup(); const user = userEvent.setup();
mockWizardState.step = 6; mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() }); render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i })); await user.click(screen.getByRole('button', { name: /create project/i }));
expect(screen.getByText(/creating/i)).toBeInTheDocument(); expect(screen.getByText(/creating/i)).toBeInTheDocument();