diff --git a/backend/alembic/env.py b/backend/alembic/env.py index afca3a491b..96c504aa5a 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -18,6 +18,7 @@ # Import app configuration and models from app.core.config import settings from app.db.base import Base +from app.db.mysql_loop_items_schema import normalize_mysql_loop_items_schema from app.db.timezone import MYSQL_SESSION_TIMEZONE_OFFSET # Import all models to ensure they are registered with SQLAlchemy @@ -138,6 +139,7 @@ def _initialize_fresh_database(connection) -> None: # This is cross-database compatible (works for both MySQL and SQLite) logger.info("Creating all tables using SQLAlchemy metadata...") Base.metadata.create_all(bind=connection, checkfirst=True) + normalize_mysql_loop_items_schema(connection) logger.info("All tables created successfully") # Step 2: Create alembic_version table and stamp to head diff --git a/backend/alembic/versions/20260804_c0d1e2f3a4b5_align_mysql_loop_items_schema.py b/backend/alembic/versions/20260804_c0d1e2f3a4b5_align_mysql_loop_items_schema.py new file mode 100644 index 0000000000..f5b330ceb5 --- /dev/null +++ b/backend/alembic/versions/20260804_c0d1e2f3a4b5_align_mysql_loop_items_schema.py @@ -0,0 +1,31 @@ +"""Align MySQL loop items with the production sentinel schema. + +Revision ID: c0d1e2f3a4b5 +Revises: b9c0d1e2f3a4 +Create Date: 2026-08-04 +""" + +from typing import Sequence, Union + +from alembic import op +from app.db.mysql_loop_items_schema import ( + normalize_mysql_loop_items_schema, + restore_nullable_mysql_loop_items_schema, +) + +revision: str = "c0d1e2f3a4b5" +down_revision: Union[str, Sequence[str], None] = "b9c0d1e2f3a4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Use sentinel values and generated unique projections on MySQL.""" + + normalize_mysql_loop_items_schema(op.get_bind()) + + +def downgrade() -> None: + """Restore nullable columns, direct unique indexes, and foreign keys.""" + + restore_nullable_mysql_loop_items_schema(op.get_bind()) diff --git a/backend/app/db/mysql_loop_items_schema.py b/backend/app/db/mysql_loop_items_schema.py new file mode 100644 index 0000000000..c35171d3c2 --- /dev/null +++ b/backend/app/db/mysql_loop_items_schema.py @@ -0,0 +1,399 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Normalize the MySQL ``loop_items`` table to its sentinel-value schema.""" + +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +import sqlalchemy as sa +from sqlalchemy.engine import Connection + + +@dataclass(frozen=True) +class SentinelColumn: + type_sql: str + value_sql: str + default_sql: str | None + + +MYSQL_LOOP_ITEM_SENTINEL_COLUMNS: dict[str, SentinelColumn] = { + "cloud_project_id": SentinelColumn("VARCHAR(64)", "''", "''"), + "parent_id": SentinelColumn("VARCHAR(64)", "''", "''"), + "loop_item_id": SentinelColumn("VARCHAR(64)", "''", "''"), + "delivery_id": SentinelColumn("VARCHAR(64)", "''", "''"), + "public_id": SentinelColumn("VARCHAR(36)", "''", "''"), + "project_key": SentinelColumn("VARCHAR(16)", "''", "''"), + "name": SentinelColumn("VARCHAR(255)", "''", "''"), + "title": SentinelColumn("VARCHAR(255)", "''", "''"), + "storage_prefix": SentinelColumn("VARCHAR(512)", "''", "''"), + "sequence_number": SentinelColumn("INTEGER", "0", "0"), + "next_item_number": SentinelColumn("INTEGER", "1", "1"), + "created_by_user_id": SentinelColumn("INTEGER", "0", "0"), + "updated_by_user_id": SentinelColumn("INTEGER", "0", "0"), + "assignee_user_id": SentinelColumn("INTEGER", "0", "0"), + "user_id": SentinelColumn("INTEGER", "0", "0"), + "added_by_user_id": SentinelColumn("INTEGER", "0", "0"), + "source": SentinelColumn("VARCHAR(20)", "''", "''"), + "status": SentinelColumn("VARCHAR(32)", "''", "''"), + "priority": SentinelColumn("VARCHAR(20)", "''", "''"), + "due_at": SentinelColumn( + "DATETIME", "'1970-01-01 00:00:01'", "'1970-01-01 00:00:01'" + ), + "current_delivery_id": SentinelColumn("VARCHAR(64)", "''", "''"), + "local_project_id": SentinelColumn("INTEGER", "0", "0"), + "device_id": SentinelColumn("VARCHAR(100)", "''", "''"), + "is_default": SentinelColumn("TINYINT(1)", "0", "0"), + "task_user_id": SentinelColumn("INTEGER", "0", "0"), + "task_id": SentinelColumn("VARCHAR(255)", "''", "''"), + "task_title": SentinelColumn("VARCHAR(255)", "''", "''"), + "backend_task_id": SentinelColumn("BIGINT", "0", "0"), + "linked_by_user_id": SentinelColumn("INTEGER", "0", "0"), + "linked_at": SentinelColumn( + "DATETIME", "'1970-01-01 00:00:01'", "'1970-01-01 00:00:01'" + ), + "unlinked_at": SentinelColumn( + "DATETIME", "'1970-01-01 00:00:01'", "'1970-01-01 00:00:01'" + ), + "path": SentinelColumn("VARCHAR(700)", "''", "''"), + "kind": SentinelColumn("VARCHAR(32)", "''", "''"), + "display_name": SentinelColumn("VARCHAR(255)", "''", "''"), + "relative_path": SentinelColumn("VARCHAR(700)", "''", "''"), + "object_key": SentinelColumn("VARCHAR(1400)", "''", "''"), + "content_type": SentinelColumn("VARCHAR(255)", "''", "''"), + "size_bytes": SentinelColumn("BIGINT", "0", "0"), + "sha256": SentinelColumn("VARCHAR(64)", "''", "''"), + "source_task_binding_id": SentinelColumn("VARCHAR(64)", "''", "''"), + "source_task_snapshot": SentinelColumn("JSON", "JSON_OBJECT()", None), + "markdown_object_key": SentinelColumn("VARCHAR(1024)", "''", "''"), + "chat_object_key": SentinelColumn("VARCHAR(1024)", "''", "''"), + "manifest_object_key": SentinelColumn("VARCHAR(1024)", "''", "''"), + "metadata": SentinelColumn("JSON", "JSON_OBJECT()", None), + "completed_at": SentinelColumn( + "DATETIME", "'1970-01-01 00:00:01'", "'1970-01-01 00:00:01'" + ), + "delivered_at": SentinelColumn( + "DATETIME", "'1970-01-01 00:00:01'", "'1970-01-01 00:00:01'" + ), + "deleted_at": SentinelColumn( + "DATETIME", "'1970-01-01 00:00:01'", "'1970-01-01 00:00:01'" + ), +} + +MYSQL_LOOP_ITEM_FOREIGN_KEYS = { + "cloud_project_id": ("loop_items", "id", "CASCADE"), + "parent_id": ("loop_items", "id", "CASCADE"), + "loop_item_id": ("loop_items", "id", "CASCADE"), + "delivery_id": ("loop_items", "id", "CASCADE"), + "local_project_id": ("projects", "id", "CASCADE"), + "backend_task_id": ("tasks", "id", "SET NULL"), +} + +MYSQL_LOOP_ITEM_UNIQUE_PROJECTIONS = { + "unique_public_id": ("public_id", "VARCHAR(36)"), + "unique_project_key": ("project_key", "VARCHAR(16)"), + "unique_storage_prefix": ("storage_prefix", "VARCHAR(512)"), +} + +MYSQL_LOOP_ITEM_UNIQUE_INDEXES = { + "uniq_loop_items_public_id": "unique_public_id", + "uniq_loop_items_project_key": "unique_project_key", + "uniq_loop_items_storage_prefix": "unique_storage_prefix", +} + +MYSQL_LOOP_ITEM_LOOKUP_INDEXES = { + "idx_loop_items_loop_item_id": ("loop_item_id",), + "idx_loop_items_delivery_id": ("delivery_id",), + "idx_loop_items_local_project_id": ("local_project_id",), + "idx_loop_items_backend_task_id": ("backend_task_id",), +} + + +def _quote(connection: Connection, identifier: str) -> str: + return connection.dialect.identifier_preparer.quote_identifier(identifier) + + +def _comment_sql(comment: str | None) -> str: + if comment is None: + return "" + escaped = comment.replace("\\", "\\\\").replace("'", "''") + return f" COMMENT '{escaped}'" + + +def _column_definition( + connection: Connection, + name: str, + spec: SentinelColumn, + *, + nullable: bool, + comment: str | None, +) -> str: + null_sql = "NULL DEFAULT NULL" if nullable else "NOT NULL" + if not nullable and spec.default_sql is not None: + null_sql += f" DEFAULT {spec.default_sql}" + return ( + f"{_quote(connection, name)} {spec.type_sql} {null_sql}" + f"{_comment_sql(comment)}" + ) + + +def _drop_mysql_foreign_keys(connection: Connection, inspector: sa.Inspector) -> None: + for foreign_key in inspector.get_foreign_keys("loop_items"): + columns = set(foreign_key.get("constrained_columns") or ()) + name = foreign_key.get("name") + if name and columns.intersection(MYSQL_LOOP_ITEM_FOREIGN_KEYS): + connection.exec_driver_sql( + "ALTER TABLE `loop_items` DROP FOREIGN KEY " + _quote(connection, name) + ) + + +def _drop_direct_unique_indexes( + connection: Connection, inspector: sa.Inspector +) -> None: + unique_columns = { + (source,) for source, _type in MYSQL_LOOP_ITEM_UNIQUE_PROJECTIONS.values() + } + indexes: dict[str, tuple[str, ...]] = {} + for index in inspector.get_indexes("loop_items"): + name = index.get("name") + columns = tuple( + column + for column in (index.get("column_names") or ()) + if isinstance(column, str) + ) + if index.get("unique") and isinstance(name, str): + indexes[name] = columns + for constraint in inspector.get_unique_constraints("loop_items"): + name = constraint.get("name") + columns = tuple( + column + for column in (constraint.get("column_names") or ()) + if isinstance(column, str) + ) + if isinstance(name, str): + indexes[name] = columns + for name, columns in indexes.items(): + if columns in unique_columns: + connection.exec_driver_sql( + "ALTER TABLE `loop_items` DROP INDEX " + _quote(connection, name) + ) + + +def _backfill_mysql_sentinels(connection: Connection, column_names: set[str]) -> None: + assignments = [] + predicates = [] + for name, spec in MYSQL_LOOP_ITEM_SENTINEL_COLUMNS.items(): + if name not in column_names: + continue + column = _quote(connection, name) + assignments.append(f"{column} = COALESCE({column}, {spec.value_sql})") + predicates.append(f"{column} IS NULL") + if not assignments: + return + connection.exec_driver_sql( + "UPDATE `loop_items` SET " + + ", ".join(assignments) + + " WHERE " + + " OR ".join(predicates) + ) + + +def _alter_sentinel_columns( + connection: Connection, + columns: Sequence[Mapping[str, Any]], + *, + nullable: bool, +) -> None: + definitions = [] + for column in columns: + name = str(column["name"]) + spec = MYSQL_LOOP_ITEM_SENTINEL_COLUMNS.get(name) + if spec is None or bool(column.get("nullable")) == nullable: + continue + comment = column.get("comment") + definitions.append( + "MODIFY COLUMN " + + _column_definition( + connection, + name, + spec, + nullable=nullable, + comment=comment if isinstance(comment, str) else None, + ) + ) + if definitions: + connection.exec_driver_sql("ALTER TABLE `loop_items` " + ", ".join(definitions)) + + +def _ensure_unique_projections( + connection: Connection, + inspector: sa.Inspector, +) -> None: + column_names = {column["name"] for column in inspector.get_columns("loop_items")} + for generated, (source, type_sql) in MYSQL_LOOP_ITEM_UNIQUE_PROJECTIONS.items(): + if generated in column_names: + continue + connection.exec_driver_sql( + f"ALTER TABLE `loop_items` ADD COLUMN {_quote(connection, generated)} " + f"{type_sql} GENERATED ALWAYS AS " + f"(NULLIF({_quote(connection, source)}, '')) VIRTUAL" + ) + + existing_names = { + index["name"] + for index in inspector.get_indexes("loop_items") + if index.get("name") + } + existing_unique_columns = { + tuple(index.get("column_names") or ()) + for index in inspector.get_indexes("loop_items") + if index.get("unique") + } + for index_name, column_name in MYSQL_LOOP_ITEM_UNIQUE_INDEXES.items(): + if index_name in existing_names or (column_name,) in existing_unique_columns: + continue + connection.exec_driver_sql( + f"CREATE UNIQUE INDEX {_quote(connection, index_name)} " + f"ON `loop_items` ({_quote(connection, column_name)})" + ) + + +def _ensure_lookup_indexes(connection: Connection, inspector: sa.Inspector) -> None: + existing_columns = { + tuple(index.get("column_names") or ()) + for index in inspector.get_indexes("loop_items") + } + for index_name, columns in MYSQL_LOOP_ITEM_LOOKUP_INDEXES.items(): + if columns in existing_columns: + continue + column_sql = ", ".join(_quote(connection, column) for column in columns) + connection.exec_driver_sql( + f"CREATE INDEX {_quote(connection, index_name)} " + f"ON `loop_items` ({column_sql})" + ) + + +def normalize_mysql_loop_items_schema(connection: Connection) -> None: + """Converge nullable/FK MySQL tables to the production sentinel schema.""" + + if connection.dialect.name != "mysql": + return + inspector = sa.inspect(connection) + if "loop_items" not in inspector.get_table_names(): + return + + columns = inspector.get_columns("loop_items") + nullable_columns = { + str(column["name"]) + for column in columns + if column.get("nullable") + and str(column["name"]) in MYSQL_LOOP_ITEM_SENTINEL_COLUMNS + } + _drop_mysql_foreign_keys(connection, inspector) + _drop_direct_unique_indexes(connection, inspector) + _backfill_mysql_sentinels(connection, nullable_columns) + _alter_sentinel_columns(connection, columns, nullable=False) + + refreshed = sa.inspect(connection) + _ensure_unique_projections(connection, refreshed) + _ensure_lookup_indexes(connection, refreshed) + + +def _drop_projection_indexes_and_columns(connection: Connection) -> None: + inspector = sa.inspect(connection) + projection_columns = set(MYSQL_LOOP_ITEM_UNIQUE_PROJECTIONS) + index_names = { + str(index["name"]) + for index in inspector.get_indexes("loop_items") + if index.get("name") + and projection_columns.intersection(index.get("column_names") or ()) + } + for index_name in index_names: + connection.exec_driver_sql( + "ALTER TABLE `loop_items` DROP INDEX " + _quote(connection, index_name) + ) + + column_names = {column["name"] for column in inspector.get_columns("loop_items")} + for column_name in MYSQL_LOOP_ITEM_UNIQUE_PROJECTIONS: + if column_name in column_names: + connection.exec_driver_sql( + "ALTER TABLE `loop_items` DROP COLUMN " + + _quote(connection, column_name) + ) + + +def _restore_nullable_constraint_values(connection: Connection) -> None: + assignments = [] + string_columns = ( + "cloud_project_id", + "parent_id", + "loop_item_id", + "delivery_id", + "public_id", + "project_key", + "storage_prefix", + ) + for name in string_columns: + column = _quote(connection, name) + assignments.append(f"{column} = NULLIF({column}, '')") + for name in ("local_project_id", "backend_task_id"): + column = _quote(connection, name) + assignments.append(f"{column} = NULLIF({column}, 0)") + connection.exec_driver_sql("UPDATE `loop_items` SET " + ", ".join(assignments)) + + +def _restore_direct_unique_indexes(connection: Connection) -> None: + inspector = sa.inspect(connection) + unique_columns = { + tuple(index.get("column_names") or ()) + for index in inspector.get_indexes("loop_items") + if index.get("unique") + } + for source, _type in MYSQL_LOOP_ITEM_UNIQUE_PROJECTIONS.values(): + if (source,) in unique_columns: + continue + name = f"uq_loop_items_{source}" + connection.exec_driver_sql( + f"CREATE UNIQUE INDEX {_quote(connection, name)} " + f"ON `loop_items` ({_quote(connection, source)})" + ) + + +def _restore_foreign_keys(connection: Connection) -> None: + inspector = sa.inspect(connection) + existing = { + tuple(foreign_key.get("constrained_columns") or ()) + for foreign_key in inspector.get_foreign_keys("loop_items") + } + for column, ( + target_table, + target_column, + on_delete, + ) in MYSQL_LOOP_ITEM_FOREIGN_KEYS.items(): + if (column,) in existing: + continue + name = f"fk_loop_items_{column}" + connection.exec_driver_sql( + f"ALTER TABLE `loop_items` ADD CONSTRAINT {_quote(connection, name)} " + f"FOREIGN KEY ({_quote(connection, column)}) " + f"REFERENCES {_quote(connection, target_table)} " + f"({_quote(connection, target_column)}) ON DELETE {on_delete}" + ) + + +def restore_nullable_mysql_loop_items_schema(connection: Connection) -> None: + """Restore the nullable/FK schema used before the convergence migration.""" + + if connection.dialect.name != "mysql": + return + inspector = sa.inspect(connection) + if "loop_items" not in inspector.get_table_names(): + return + + _drop_projection_indexes_and_columns(connection) + columns = sa.inspect(connection).get_columns("loop_items") + _alter_sentinel_columns(connection, columns, nullable=True) + _restore_nullable_constraint_values(connection) + _restore_direct_unique_indexes(connection) + _restore_foreign_keys(connection) diff --git a/backend/app/models/delivery.py b/backend/app/models/delivery.py index a8db94cbc8..08e1ad0f48 100644 --- a/backend/app/models/delivery.py +++ b/backend/app/models/delivery.py @@ -249,6 +249,7 @@ class DeliveryAsset(LoopNode): "title": "", "storage_prefix": "", "sequence_number": 0, + "next_item_number": 1, "created_by_user_id": 0, "updated_by_user_id": 0, "assignee_user_id": 0, @@ -314,6 +315,11 @@ def loop_datetime_value_is_unset(value: datetime | None) -> bool: return value is None or value == _MYSQL_UNSET_DATETIME +def loop_datetime_unset_value_for_dialect(dialect_name: str) -> datetime | None: + """Return the database representation for an unset loop-node datetime.""" + return _MYSQL_UNSET_DATETIME if dialect_name == "mysql" else None + + @event.listens_for(LoopNode, "before_insert", propagate=True) def _populate_mysql_non_null_defaults( _mapper: object, connection: Connection, target: LoopNode diff --git a/backend/app/services/cloud_projects/service.py b/backend/app/services/cloud_projects/service.py index de82068703..78bae97bb1 100644 --- a/backend/app/services/cloud_projects/service.py +++ b/backend/app/services/cloud_projects/service.py @@ -15,7 +15,11 @@ from app.core.provider_credentials import store_provider_config from app.models.cloud_project import CloudProject, CloudProjectLocalBinding -from app.models.delivery import LoopItem, loop_datetime_is_unset +from app.models.delivery import ( + LoopItem, + adapt_loop_node_values_for_dialect, + loop_datetime_is_unset, +) from app.models.project import Project from app.models.resource_member import MemberStatus, ResourceMember from app.models.share_link import ResourceType @@ -182,14 +186,17 @@ def update( next_ids = {item.id for item in values.board_config.statuses} removed_ids = previous_ids - next_ids if removed_ids: + item_updates = adapt_loop_node_values_for_dialect( + {"status": "", "completed_at": None}, + db.get_bind().dialect.name, + ) db.query(LoopItem).filter( LoopItem.cloud_project_id == project.id, LoopItem.status.in_(removed_ids), loop_datetime_is_unset(LoopItem.deleted_at), ).update( { - "status": "", - "completed_at": None, + **item_updates, "version": LoopItem.version + 1, }, synchronize_session=False, diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index 31eff9a567..60762c4aa0 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -27,6 +27,7 @@ LoopItemCollaborator, adapt_loop_node_values_for_dialect, loop_datetime_is_unset, + loop_datetime_unset_value_for_dialect, loop_datetime_value_is_unset, ) from app.models.resource_member import MemberStatus, ResourceMember @@ -612,7 +613,9 @@ def restore(self, db: Session, item_id: str, user_id: int) -> LoopItem: self._require_item_access(db, item, user_id, edit=True) if loop_datetime_value_is_unset(item.deleted_at): raise HTTPException(status.HTTP_409_CONFLICT, "TODO is not deleted") - item.deleted_at = None + item.deleted_at = loop_datetime_unset_value_for_dialect( + db.get_bind().dialect.name + ) item.version += 1 db.commit() db.refresh(item) diff --git a/backend/tests/models/test_loop_items_mysql_schema.py b/backend/tests/models/test_loop_items_mysql_schema.py new file mode 100644 index 0000000000..55a4539fab --- /dev/null +++ b/backend/tests/models/test_loop_items_mysql_schema.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Contract tests for the MySQL loop-item sentinel schema.""" + +import importlib.util +from pathlib import Path + +import pytest + +from app.db.mysql_loop_items_schema import ( + MYSQL_LOOP_ITEM_FOREIGN_KEYS, + MYSQL_LOOP_ITEM_SENTINEL_COLUMNS, + MYSQL_LOOP_ITEM_UNIQUE_PROJECTIONS, +) + +pytestmark = pytest.mark.unit + + +def test_mysql_sentinel_schema_covers_optional_constrained_columns() -> None: + expected_foreign_keys = { + "cloud_project_id", + "parent_id", + "loop_item_id", + "delivery_id", + "local_project_id", + "backend_task_id", + } + expected_unique_sources = {"public_id", "project_key", "storage_prefix"} + + assert set(MYSQL_LOOP_ITEM_FOREIGN_KEYS) == expected_foreign_keys + assert expected_foreign_keys.issubset(MYSQL_LOOP_ITEM_SENTINEL_COLUMNS) + assert { + source for source, _type in MYSQL_LOOP_ITEM_UNIQUE_PROJECTIONS.values() + } == expected_unique_sources + assert expected_unique_sources.issubset(MYSQL_LOOP_ITEM_SENTINEL_COLUMNS) + + +def test_mysql_sentinel_schema_migration_is_current_head() -> None: + migration_path = ( + Path(__file__).resolve().parents[2] + / "alembic" + / "versions" + / "20260804_c0d1e2f3a4b5_align_mysql_loop_items_schema.py" + ) + spec = importlib.util.spec_from_file_location( + "mysql_loop_items_schema_migration", migration_path + ) + assert spec and spec.loader + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + + assert migration.revision == "c0d1e2f3a4b5" + assert migration.down_revision == "b9c0d1e2f3a4" diff --git a/backend/tests/schemas/test_delivery.py b/backend/tests/schemas/test_delivery.py index 8ae9275390..13d67dadf5 100644 --- a/backend/tests/schemas/test_delivery.py +++ b/backend/tests/schemas/test_delivery.py @@ -6,7 +6,10 @@ from datetime import datetime -from app.models.delivery import adapt_loop_node_values_for_dialect +from app.models.delivery import ( + adapt_loop_node_values_for_dialect, + loop_datetime_unset_value_for_dialect, +) from app.schemas.delivery import LoopItemResponse @@ -42,15 +45,44 @@ def test_loop_item_response_normalizes_mysql_sentinel_values() -> None: assert response.completed_at is None -def test_loop_item_update_adapts_nulls_only_for_mysql() -> None: - values = {"parent_id": None, "due_at": None, "completed_at": None} +def test_loop_node_adaptation_uses_mysql_sentinels() -> None: + values = { + "cloud_project_id": None, + "parent_id": None, + "loop_item_id": None, + "delivery_id": None, + "public_id": None, + "project_key": None, + "storage_prefix": None, + "next_item_number": None, + "local_project_id": None, + "backend_task_id": None, + "due_at": None, + "completed_at": None, + } mysql_values = adapt_loop_node_values_for_dialect(values, "mysql") sqlite_values = adapt_loop_node_values_for_dialect(values, "sqlite") assert mysql_values == { + "cloud_project_id": "", "parent_id": "", + "loop_item_id": "", + "delivery_id": "", + "public_id": "", + "project_key": "", + "storage_prefix": "", + "next_item_number": 1, + "local_project_id": 0, + "backend_task_id": 0, "due_at": datetime(1970, 1, 1, 0, 0, 1), "completed_at": datetime(1970, 1, 1, 0, 0, 1), } assert sqlite_values == values + + +def test_loop_datetime_unset_value_matches_dialect_schema() -> None: + assert loop_datetime_unset_value_for_dialect("mysql") == datetime( + 1970, 1, 1, 0, 0, 1 + ) + assert loop_datetime_unset_value_for_dialect("sqlite") is None diff --git a/frontend/e2e/tests/api/cloud-project-task-binding-api.spec.ts b/frontend/e2e/tests/api/cloud-project-task-binding-api.spec.ts new file mode 100644 index 0000000000..0e4dc10696 --- /dev/null +++ b/frontend/e2e/tests/api/cloud-project-task-binding-api.spec.ts @@ -0,0 +1,129 @@ +import { expect, test } from '@playwright/test' +import { ADMIN_USER } from '../../config/test-users' + +const API_BASE_URL = process.env.E2E_API_URL || 'http://localhost:8000' + +interface CloudProjectResponse { + id: string + name: string + version: number +} + +test.describe('API - Cloud project task binding', () => { + let authorization: { Authorization: string } + let project: CloudProjectResponse | null = null + let runtimeTask: { deviceId: string; taskId: string } | null = null + + test.beforeEach(async ({ request }) => { + const loginResponse = await request.post(`${API_BASE_URL}/api/auth/login`, { + data: { + user_name: ADMIN_USER.username, + password: ADMIN_USER.password, + }, + }) + expect(loginResponse.status()).toBe(200) + const login = (await loginResponse.json()) as { access_token: string } + authorization = { Authorization: `Bearer ${login.access_token}` } + }) + + test.afterEach(async ({ request }) => { + if (runtimeTask) { + const unbindResponse = await request.delete( + `${API_BASE_URL}/api/v1/runtime-tasks/cloud-context`, + { + headers: authorization, + data: runtimeTask, + } + ) + expect(unbindResponse.status()).toBe(204) + runtimeTask = null + } + if (project) { + const archiveResponse = await request.delete( + `${API_BASE_URL}/api/v1/cloud-projects/${project.id}?version=${project.version}`, + { headers: authorization } + ) + expect(archiveResponse.status()).toBe(204) + project = null + } + }) + + test('preserves a null TODO when binding a task to a project', async ({ request }) => { + const unique = `${Date.now().toString(36)}${test.info().workerIndex.toString(36)}` + const createResponse = await request.post(`${API_BASE_URL}/api/v1/cloud-projects`, { + headers: authorization, + data: { + project_key: `E${unique}`.slice(0, 16), + name: `MySQL project binding ${unique}`, + }, + }) + expect(createResponse.status()).toBe(201) + project = (await createResponse.json()) as CloudProjectResponse + + runtimeTask = { + deviceId: `mysql-e2e-device-${unique}`, + taskId: `mysql-e2e-task-${unique}`, + } + const bindResponse = await request.post( + `${API_BASE_URL}/api/v1/cloud-projects/${project.id}/tasks`, + { + headers: authorization, + data: runtimeTask, + } + ) + expect(bindResponse.status()).toBe(201) + const binding = (await bindResponse.json()) as { + cloud_project_id: string + loop_item_id: string | null + } + expect(binding.cloud_project_id).toBe(project.id) + expect(binding.loop_item_id).toBeNull() + + const query = new URLSearchParams({ + device_id: runtimeTask.deviceId, + task_id: runtimeTask.taskId, + }) + const contextResponse = await request.get( + `${API_BASE_URL}/api/v1/runtime-tasks/cloud-context?${query}`, + { headers: authorization } + ) + expect(contextResponse.status()).toBe(200) + const context = (await contextResponse.json()) as { + loop_item: unknown | null + project: CloudProjectResponse + } + expect(context.project.id).toBe(project.id) + expect(context.loop_item).toBeNull() + + const itemResponse = await request.post( + `${API_BASE_URL}/api/v1/cloud-projects/${project.id}/loop-items`, + { + headers: authorization, + data: { title: `MySQL TODO ${unique}` }, + } + ) + expect(itemResponse.status()).toBe(201) + const item = (await itemResponse.json()) as { id: string } + + const todoBindingResponse = await request.post( + `${API_BASE_URL}/api/v1/loop-items/${item.id}/tasks`, + { + headers: authorization, + data: runtimeTask, + } + ) + expect(todoBindingResponse.status()).toBe(201) + + const narrowedResponse = await request.get( + `${API_BASE_URL}/api/v1/runtime-tasks/cloud-context?${query}`, + { headers: authorization } + ) + expect(narrowedResponse.status()).toBe(200) + const narrowed = (await narrowedResponse.json()) as { + loop_item: { id: string } | null + project: CloudProjectResponse + } + expect(narrowed.project.id).toBe(project.id) + expect(narrowed.loop_item?.id).toBe(item.id) + }) +})