feat(v4.1.0): Data Sources & Linked Databases
- Add collection_data_sources table for multi-source collections
- API: GET/POST/DELETE /db/{id}/sources/api — data source management
- API: POST /db/{id}/linked/api — create linked database (copies views/properties)
- API: POST /db/{id}/toggle-inline/api — toggle full-page vs inline
- API: POST /db/inline/api — create inline database
- Linked DB inherits workspace_id (permissions) from source
- Migration: add workspace_id + created_by to collections table
- 10 new tests (153 total)
This commit is contained in:
@@ -462,6 +462,31 @@ def init_db():
|
|||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# v4.1.0: Data Sources & Linked Databases
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS collection_data_sources (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
source_collection_id INTEGER NOT NULL REFERENCES collections(id),
|
||||||
|
source_name TEXT DEFAULT '',
|
||||||
|
is_linked BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(collection_id, source_collection_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Add workspace_id to collections if missing (v4.1.0 migration)
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE collections ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
# Add created_by to collections if missing
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE collections ADD COLUMN created_by INTEGER REFERENCES users(id)")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_conn():
|
def get_conn():
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, Query
|
from fastapi import APIRouter, Request, HTTPException, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -760,6 +761,255 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.1.0: Data Sources & Linked Databases ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{collection_id}/sources/api")
|
||||||
|
async def list_data_sources(request: Request, collection_id: int):
|
||||||
|
"""API: list all data sources for a collection."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
|
if not coll:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {"sources": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{collection_id}/sources/api")
|
||||||
|
async def add_data_source(request: Request, collection_id: int):
|
||||||
|
"""API: add a data source to a collection."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
source_collection_id = body.get("source_collection_id")
|
||||||
|
if not source_collection_id:
|
||||||
|
raise HTTPException(status_code=400, detail="source_collection_id is required")
|
||||||
|
|
||||||
|
source_name = body.get("source_name", "").strip()
|
||||||
|
is_linked = body.get("is_linked", False)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
# Verify both collections exist
|
||||||
|
for cid in (collection_id, source_collection_id):
|
||||||
|
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
|
||||||
|
|
||||||
|
max_pos = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_data_sources WHERE collection_id=?",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO collection_data_sources
|
||||||
|
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(collection_id, source_collection_id, source_name, int(is_linked), max_pos),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
raise HTTPException(status_code=409, detail="This data source already exists in this collection")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": cur.lastrowid,
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"source_collection_id": source_collection_id,
|
||||||
|
"status": "added",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{collection_id}/sources/{source_id}/api")
|
||||||
|
async def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||||
|
"""API: remove a data source from a collection."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?",
|
||||||
|
(source_id, collection_id),
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Data source not found")
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"id": source_id, "status": "removed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{collection_id}/linked/api")
|
||||||
|
async def create_linked_database(request: Request, collection_id: int):
|
||||||
|
"""API: create a linked database view from a source collection.
|
||||||
|
A linked database copies the structure (views, filters, sorts) of a source
|
||||||
|
but shares the same pages — edits to pages propagate to the source.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
name = body.get("name", "").strip()
|
||||||
|
new_workspace_id = body.get("workspace_id")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
source = conn.execute(
|
||||||
|
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Source collection not found")
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
name = f"{source['name']} (linked)"
|
||||||
|
|
||||||
|
# Create the linked collection (shallow copy of structure)
|
||||||
|
# Inherit workspace_id from source for permission inheritance
|
||||||
|
src_dict = dict(source)
|
||||||
|
source_workspace_id = src_dict.get("workspace_id")
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO collections
|
||||||
|
(name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
src_dict["description"],
|
||||||
|
src_dict["icon"],
|
||||||
|
src_dict["schema_json"],
|
||||||
|
0, # linked DB is never locked
|
||||||
|
1, # linked DB starts as inline
|
||||||
|
src_dict.get("parent_page_id"),
|
||||||
|
source_workspace_id, # linked DB inherits source workspace permissions
|
||||||
|
),
|
||||||
|
)
|
||||||
|
linked_id = cur.lastrowid
|
||||||
|
|
||||||
|
# Copy views from source
|
||||||
|
views = conn.execute(
|
||||||
|
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
for v in views:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(linked_id, v["name"], v["view_type"], v["config_json"], v["position"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the source as a data source with is_linked=1
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO collection_data_sources
|
||||||
|
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||||
|
VALUES (?, ?, ?, 1, 0)""",
|
||||||
|
(linked_id, collection_id, source["name"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy properties from source
|
||||||
|
props = conn.execute(
|
||||||
|
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
for p in props:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO collection_properties
|
||||||
|
(collection_id, name, prop_type, options_json, number_format,
|
||||||
|
related_collection_id, reverse_name, relation_property_id,
|
||||||
|
target_property_id, rollup_function, formula_expression,
|
||||||
|
position, required, visible_in_views)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
linked_id, p["name"], p["prop_type"], p["options_json"],
|
||||||
|
p["number_format"], p["related_collection_id"], p["reverse_name"],
|
||||||
|
p["relation_property_id"], p["target_property_id"],
|
||||||
|
p["rollup_function"], p["formula_expression"],
|
||||||
|
p["position"], p["required"], p["visible_in_views"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"linked_id": linked_id,
|
||||||
|
"name": name,
|
||||||
|
"source_collection_id": collection_id,
|
||||||
|
"status": "created",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{collection_id}/toggle-inline/api")
|
||||||
|
async def toggle_inline(request: Request, collection_id: int):
|
||||||
|
"""API: toggle a collection between full-page and inline mode."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
coll = conn.execute(
|
||||||
|
"SELECT id, is_inline FROM collections WHERE id=?",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not coll:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
|
||||||
|
new_inline = 0 if coll["is_inline"] else 1
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(new_inline, collection_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"is_inline": bool(new_inline),
|
||||||
|
"mode": "inline" if new_inline else "full-page",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/inline/api")
|
||||||
|
async def create_inline_database(request: Request):
|
||||||
|
"""API: create an inline database within a parent page."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
name = body.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
|
|
||||||
|
description = body.get("description", "")
|
||||||
|
icon = body.get("icon", "📋")
|
||||||
|
parent_page_id = body.get("parent_page_id")
|
||||||
|
workspace_id = body.get("workspace_id")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO collections
|
||||||
|
(name, description, icon, schema_json, is_inline, parent_page_id)
|
||||||
|
VALUES (?, ?, ?, '[]', 1, ?)""",
|
||||||
|
(name, description, icon, parent_page_id),
|
||||||
|
)
|
||||||
|
collection_id = cur.lastrowid
|
||||||
|
|
||||||
|
# Create default view
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO collection_views
|
||||||
|
(collection_id, name, view_type, config_json)
|
||||||
|
VALUES (?, ?, ?, ?)""",
|
||||||
|
(collection_id, "Default View", "table", json.dumps({
|
||||||
|
"visible_properties": ["Title"],
|
||||||
|
"sorts": [],
|
||||||
|
"filters": [],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": collection_id,
|
||||||
|
"name": name,
|
||||||
|
"is_inline": True,
|
||||||
|
"parent_page_id": parent_page_id,
|
||||||
|
"status": "created",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2002,3 +2002,229 @@ def test_session_expired_redirect(client):
|
|||||||
location = resp.headers.get("location", "")
|
location = resp.headers.get("location", "")
|
||||||
assert "login" in location
|
assert "login" in location
|
||||||
assert "expired=1" in location
|
assert "expired=1" in location
|
||||||
|
|
||||||
|
|
||||||
|
# ── v4.1.0: Data Sources & Linked Databases ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_sources_list_empty(client):
|
||||||
|
"""List data sources for a collection — empty by default."""
|
||||||
|
# Create a collection first
|
||||||
|
resp = client.post("/db/api", json={"name": "Sources Test DB"})
|
||||||
|
coll_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.get(f"/db/{coll_id}/sources/api")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["sources"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_sources_add_and_remove(client):
|
||||||
|
"""Add and remove a data source from a collection."""
|
||||||
|
# Create two collections
|
||||||
|
r1 = client.post("/db/api", json={"name": "Source A"})
|
||||||
|
r2 = client.post("/db/api", json={"name": "Source B"})
|
||||||
|
coll_a = r1.json()["id"]
|
||||||
|
coll_b = r2.json()["id"]
|
||||||
|
|
||||||
|
# Add B as a data source of A
|
||||||
|
resp = client.post(f"/db/{coll_a}/sources/api", json={
|
||||||
|
"source_collection_id": coll_b,
|
||||||
|
"source_name": "Linked B",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "added"
|
||||||
|
source_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# List — should have 1 source
|
||||||
|
resp = client.get(f"/db/{coll_a}/sources/api")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["sources"]) == 1
|
||||||
|
assert resp.json()["sources"][0]["source_name"] == "Linked B"
|
||||||
|
|
||||||
|
# Remove the source
|
||||||
|
resp = client.delete(f"/db/{coll_a}/sources/{source_id}/api")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "removed"
|
||||||
|
|
||||||
|
# List — should be empty again
|
||||||
|
resp = client.get(f"/db/{coll_a}/sources/api")
|
||||||
|
assert len(resp.json()["sources"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_sources_duplicate_rejected(client):
|
||||||
|
"""Adding the same data source twice returns 409."""
|
||||||
|
r1 = client.post("/db/api", json={"name": "Dup Source A"})
|
||||||
|
r2 = client.post("/db/api", json={"name": "Dup Source B"})
|
||||||
|
coll_a = r1.json()["id"]
|
||||||
|
coll_b = r2.json()["id"]
|
||||||
|
|
||||||
|
# First add — OK
|
||||||
|
resp = client.post(f"/db/{coll_a}/sources/api", json={
|
||||||
|
"source_collection_id": coll_b,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Second add — conflict
|
||||||
|
resp = client.post(f"/db/{coll_a}/sources/api", json={
|
||||||
|
"source_collection_id": coll_b,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_sources_not_found(client):
|
||||||
|
"""Non-existent collection returns 404."""
|
||||||
|
resp = client.get("/db/99999/sources/api")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_linked_database(client):
|
||||||
|
"""Create a linked database from a source collection."""
|
||||||
|
# Create source collection
|
||||||
|
resp = client.post("/db/api", json={
|
||||||
|
"name": "CRM Contacts",
|
||||||
|
"description": "Customer contacts",
|
||||||
|
"icon": "👥",
|
||||||
|
})
|
||||||
|
source_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Add a property to the source
|
||||||
|
client.post(f"/db/{source_id}/properties/api", json={
|
||||||
|
"name": "Email",
|
||||||
|
"prop_type": "email",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create linked DB
|
||||||
|
resp = client.post(f"/db/{source_id}/linked/api", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "created"
|
||||||
|
assert data["source_collection_id"] == source_id
|
||||||
|
linked_id = data["linked_id"]
|
||||||
|
assert linked_id != source_id
|
||||||
|
|
||||||
|
# Linked DB should have a data source pointing to source
|
||||||
|
resp = client.get(f"/db/{linked_id}/sources/api")
|
||||||
|
sources = resp.json()["sources"]
|
||||||
|
assert len(sources) == 1
|
||||||
|
assert sources[0]["source_collection_id"] == source_id
|
||||||
|
assert sources[0]["is_linked"] == 1
|
||||||
|
|
||||||
|
# Linked DB should have copied the source's properties
|
||||||
|
resp = client.get(f"/db/{linked_id}/properties/api")
|
||||||
|
props = resp.json()["properties"]
|
||||||
|
assert any(p["name"] == "Email" for p in props)
|
||||||
|
|
||||||
|
# Linked DB should have copied the source's views
|
||||||
|
resp = client.get(f"/db/{linked_id}/views/api")
|
||||||
|
views = resp.json()["views"]
|
||||||
|
assert len(views) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_linked_database_custom_name(client):
|
||||||
|
"""Create a linked database with a custom name."""
|
||||||
|
resp = client.post("/db/api", json={"name": "Task DB"})
|
||||||
|
source_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/db/{source_id}/linked/api", json={
|
||||||
|
"name": "My Linked Tasks",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "My Linked Tasks"
|
||||||
|
assert data["status"] == "created"
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_inline(client):
|
||||||
|
"""Toggle a collection between full-page and inline modes."""
|
||||||
|
resp = client.post("/db/api", json={"name": "Toggle Test"})
|
||||||
|
coll_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Start as full-page (is_inline=0 by default)
|
||||||
|
resp = client.get(f"/db/{coll_id}/api")
|
||||||
|
assert resp.json()["collection"]["is_inline"] == 0
|
||||||
|
|
||||||
|
# Toggle to inline
|
||||||
|
resp = client.post(f"/db/{coll_id}/toggle-inline/api")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_inline"] is True
|
||||||
|
assert resp.json()["mode"] == "inline"
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
resp = client.get(f"/db/{coll_id}/api")
|
||||||
|
assert resp.json()["collection"]["is_inline"] == 1
|
||||||
|
|
||||||
|
# Toggle back to full-page
|
||||||
|
resp = client.post(f"/db/{coll_id}/toggle-inline/api")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_inline"] is False
|
||||||
|
assert resp.json()["mode"] == "full-page"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_inline_database(client):
|
||||||
|
"""Create an inline database within a parent page."""
|
||||||
|
# Create a page first (dummy)
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO pages (workspace, title) VALUES ('test', 'Parent Page')"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
page_id = conn.execute("SELECT id FROM pages ORDER BY id DESC LIMIT 1").fetchone()["id"]
|
||||||
|
|
||||||
|
resp = client.post("/db/inline/api", json={
|
||||||
|
"name": "Inline Comments DB",
|
||||||
|
"description": "Inline comments database",
|
||||||
|
"parent_page_id": page_id,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "created"
|
||||||
|
assert data["is_inline"] is True
|
||||||
|
assert data["parent_page_id"] == page_id
|
||||||
|
|
||||||
|
# Verify the collection exists with inline flag
|
||||||
|
resp = client.get(f"/db/{data['id']}/api")
|
||||||
|
assert resp.json()["collection"]["is_inline"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_db_inherits_workspace(client):
|
||||||
|
"""Linked database should inherit the source's workspace_id."""
|
||||||
|
# Create workspace
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workspaces (name, owner_id) VALUES ('Test WS', 1)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
ws_id = conn.execute("SELECT id FROM workspaces ORDER BY id DESC LIMIT 1").fetchone()["id"]
|
||||||
|
|
||||||
|
# Create collection with workspace_id
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO collections (name, workspace_id) VALUES (?, ?)",
|
||||||
|
("WS Collection", ws_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
coll_id = conn.execute(
|
||||||
|
"SELECT id FROM collections WHERE name='WS Collection'"
|
||||||
|
).fetchone()["id"]
|
||||||
|
|
||||||
|
# Create linked DB
|
||||||
|
resp = client.post(f"/db/{coll_id}/linked/api", json={"name": "Linked WS DB"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
linked_id = resp.json()["linked_id"]
|
||||||
|
|
||||||
|
# Verify linked DB has same workspace_id
|
||||||
|
with get_conn() as conn:
|
||||||
|
linked = conn.execute(
|
||||||
|
"SELECT workspace_id FROM collections WHERE id=?", (linked_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert linked["workspace_id"] == ws_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_data_source_not_found(client):
|
||||||
|
"""Deleting a non-existent data source returns 404."""
|
||||||
|
resp = client.post("/db/api", json={"name": "Remove Test"})
|
||||||
|
coll_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.delete(f"/db/{coll_id}/sources/99999/api")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|||||||
Reference in New Issue
Block a user