/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Real home counts, empty states and permission-scoped results.
*******************************************************************************/
class DataAnalysisHomeTest
{
/*******************************************************************************
** Release the thread-local context.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** Empty counts explain why; missing permissions are not represented as zero.
*******************************************************************************/
@Test
void emptyAndDenied() throws Exception
{
QInstance instance = TestSupport.instance();
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.watch.read", "data_analysis.run.read"));
String html = DataAnalysisHome.render(Instant.now());
assertThat(html).contains("Mine: 0", "Starred by me: 0", "No saved analyses yet",
"No watches yet.", "No analysis runs yet.", "View all", "Open any table and choose Analyze on a column header to explore its data.", "
");
var widget = new DataAnalysisHome().render(new RenderWidgetInput()).getWidgetData();
assertThat(widget).isInstanceOf(com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML.class);
assertThat(((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) widget).getType()).isEqualTo("html");
assertThat(html).contains("·").doesNotContain("Â", "later", "phase", "
c < 128)).isTrue();
QContext.init(instance, TestSupport.user("alice"));
html = DataAnalysisHome.render(Instant.now());
assertThat(html).contains("you need data_analysis.saved.read", "you need data_analysis.watch.read").doesNotContain("Mine: 0", "Complete: 0");
QContext.init(instance, new QSession());
assertThat(DataAnalysisHome.render(Instant.now())).contains("Sign in to see your saved analyses");
}
/*******************************************************************************
** Different owners, shared flags, stars, disabled watches and date boundaries.
*******************************************************************************/
@Test
void countsUseCurrentUserAndRealRows() throws Exception
{
QInstance instance = TestSupport.instance();
Instant now = Instant.parse("2026-10-13T12:00:00Z");
QRecord mine = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", true));
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false));
QRecord bob = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", true));
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", mine.getValue("id")).withValue("user_id", "alice"));
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", bob.getValue("id")).withValue("user_id", "alice"));
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", mine.getValue("id")).withValue("user_id", "bob"));
TestSupport.insert(DataAnalysisTables.WATCH, new QRecord().withValue("detector", "SPIKE").withValue("enabled", true).withValue("state", "FIRING"));
TestSupport.insert(DataAnalysisTables.WATCH, new QRecord().withValue("detector", "DROP").withValue("enabled", true));
TestSupport.insert(DataAnalysisTables.WATCH, new QRecord().withValue("detector", "DROP").withValue("enabled", false));
run("alice", "COMPLETE", now);
run("alice", "COMPLETE", now.minus(7, ChronoUnit.DAYS));
run("alice", "FAILED", now.minus(1, ChronoUnit.DAYS));
run("alice", "COMPLETE", now.minus(7, ChronoUnit.DAYS).minusSeconds(1));
run("alice", "FAILED", now.plusSeconds(1));
run("alice", "RUNNING", now);
run("bob", "FAILED", now);
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.watch.read", "data_analysis.run.read"));
String html = DataAnalysisHome.render(now);
assertThat(html).contains("Mine: 2", "Shared (visible to you): 1", "Starred by me: 1", "Enabled: 2", "Firing: 1", "Recent analysis runs", "Complete", "Failed", "Running", "Not finished")
.doesNotContain("No saved analyses", "No watches yet", "No analysis runs yet");
QContext.init(instance, TestSupport.user("bob").withPermissions("data_analysis.saved.read", "data_analysis.run.read"));
html = DataAnalysisHome.render(now);
assertThat(html).contains("Mine: 1", "Shared (visible to you): 1", "Starred by me: 0", "Failed", "you need data_analysis.watch.read");
}
/*******************************************************************************
** Latest five remain owner-scoped even with administrator bypass and escape HTML.
*******************************************************************************/
@Test
void recentRunDetailsAndLimit() throws Exception
{
QInstance instance = TestSupport.instance();
instance.getTable(DataAnalysisTables.SAVED).withLabel("Saved & review");
Instant now = Instant.parse("2026-10-13T23:10:00Z");
for(int i = 0; i < 7; i++)
{
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", DataAnalysisTables.SAVED)
.withValue("operation", "COLUMN_PROFILE").withValue("user_id", "admin").withValue("status", "COMPLETE")
.withValue("started_at", now.minusSeconds(i * 60L)).withValue("finished_at", now.minusSeconds(i * 60L)));
}
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", "private_bob_table")
.withValue("operation", "TABLE_PROFILE").withValue("user_id", "bob").withValue("status", "FAILED").withValue("started_at", now));
QContext.init(instance, TestSupport.admin().withValue(QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago"));
String html = DataAnalysisHome.render(now);
assertThat(html).contains("Saved <analysis> & review", "Column Profile", "2026-10-13 18:10", "2026-10-13 18:06", "");
assertThat(html.split("- ", -1)).hasSize(6);
assertThat(html).contains("href=\"" + instance.getTablePath(DataAnalysisTables.RUN) + "/1\"");
assertThat(html).contains("href=\"" + instance.getTablePath(DataAnalysisTables.RUN) + "\">View all");
}
/*******************************************************************************
** Insert a real run row with an explicit owner and start time.
*******************************************************************************/
private static void run(String user, String status, Instant started) throws Exception
{
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", "orders").withValue("operation", "COLUMN_PROFILE")
.withValue("user_id", user).withValue("status", status).withValue("started_at", started));
}
}