|
|
@@ -0,0 +1,243 @@
|
|
|
+package com.adx.tencent.httpapi;
|
|
|
+
|
|
|
+import com.adx.tencent.baidu.AdxClient;
|
|
|
+import com.adx.tencent.baidu.model.*;
|
|
|
+import com.adx.tencent.storage.RedisHotStore;
|
|
|
+import com.adx.tencent.storage.model.BidRecord;
|
|
|
+import com.fasterxml.jackson.databind.json.JsonMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
+import org.springframework.mock.web.MockHttpServletResponse;
|
|
|
+import org.springframework.web.server.ResponseStatusException;
|
|
|
+import org.springframework.util.DigestUtils;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.*;
|
|
|
+import java.util.concurrent.*;
|
|
|
+import java.util.concurrent.atomic.AtomicLong;
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+class DeviceTrackingTest {
|
|
|
+ final RedisHotStore store = mock(RedisHotStore.class);
|
|
|
+ final AdxClient client = mock(AdxClient.class);
|
|
|
+ final Map<String, Long> claims = new ConcurrentHashMap<>();
|
|
|
+ final AtomicLong now = new AtomicLong();
|
|
|
+ TrackingController controller;
|
|
|
+
|
|
|
+ @BeforeEach void setup() throws Exception {
|
|
|
+ MediaPlacement placement = new MediaPlacement();
|
|
|
+ placement.setBaiduAppId("app"); placement.setBaiduTagId("jlt130");
|
|
|
+ controller = new TrackingController(client, store, placement,
|
|
|
+ JsonMapper.builder().findAndAddModules().build(), "test");
|
|
|
+ when(store.claimDeviceTracking(anyString(), anyString())).thenAnswer(i -> {
|
|
|
+ String key = i.getArgument(0) + ":" + i.getArgument(1);
|
|
|
+ synchronized (claims) {
|
|
|
+ if (claims.getOrDefault(key, -1L) > now.get()) return false;
|
|
|
+ claims.put(key, now.get() + 86400); return true;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ NormalizedBid bid = new NormalizedBid();
|
|
|
+ bid.setShowUrls(List.of("https://example.com/show")); bid.setPrice(100L);
|
|
|
+ bid.setClickUrls(Arrays.asList(null, "", "bad", "https://example.com/click1", "https://example.com/click2"));
|
|
|
+ bid.setLandingPage("https://example.com/landing");
|
|
|
+ NormalizedBidResponse response = new NormalizedBidResponse();
|
|
|
+ response.setQk("qk"); response.setBid(bid);
|
|
|
+ when(client.requestBid(anyString(), anyString(), any())).thenReturn(response);
|
|
|
+ when(client.reportImpression(anyList(), anyLong(), anyBoolean())).thenReturn(List.of());
|
|
|
+ when(client.reportClick(anyList(), anyBoolean())).thenReturn(List.of());
|
|
|
+ }
|
|
|
+
|
|
|
+ MockHttpServletRequest request(String tag, String trace) {
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest();
|
|
|
+ req.setParameter("tag_id", tag); req.setParameter("click_id", trace); req.setParameter("oaid", "device-A");
|
|
|
+ return req;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void repeatsAndVersionsKeepOriginalBidAndShrinkFlowButReportOnlyOnce() throws Exception {
|
|
|
+ controller.tencentImpression(request("jlt130", "i1"), 0);
|
|
|
+ controller.tencentImpressionV2(request("jlt130", "i2"));
|
|
|
+ for (int n = 0; n < 3; n++) {
|
|
|
+ MockHttpServletResponse res = new MockHttpServletResponse();
|
|
|
+ controller.tencentClickV2(request("jlt130", "c" + n), res);
|
|
|
+ assertEquals("https://example.com/landing", res.getRedirectedUrl());
|
|
|
+ }
|
|
|
+ verify(client, times(5)).requestBid(anyString(), anyString(), any());
|
|
|
+ verify(client, times(1)).reportImpression(anyList(), eq(100L), eq(false));
|
|
|
+ verify(client, times(1)).reportClick(List.of("https://example.com/click1"), false);
|
|
|
+ verify(store, times(3)).shrinkAfterClick(any());
|
|
|
+ verify(store, times(5)).recordBid(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void clickFirstAndSeparatePlacementsAndDevices() throws Exception {
|
|
|
+ controller.tencentClick(request("jlt130", "c1"), new MockHttpServletResponse(), 0);
|
|
|
+ controller.tencentImpression(request("jlt130", "i1"), 0);
|
|
|
+ controller.tencentClick(request("jlt11", "c2"), new MockHttpServletResponse(), 0);
|
|
|
+ MockHttpServletRequest other = request("jlt130", "c3"); other.setParameter("oaid", "device-B");
|
|
|
+ controller.tencentClick(other, new MockHttpServletResponse(), 0);
|
|
|
+ verify(client, times(4)).requestBid(anyString(), anyString(), any());
|
|
|
+ verify(client, times(3)).reportImpression(anyList(), anyLong(), eq(false));
|
|
|
+ verify(client, times(3)).reportClick(anyList(), eq(false));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void rollingWindowsAreIndependentAndNotExtendedByDuplicates() throws Exception {
|
|
|
+ controller.tencentImpression(request("jlt130", "i1"), 0);
|
|
|
+ now.set(300);
|
|
|
+ controller.tencentClick(request("jlt130", "c1"), new MockHttpServletResponse(), 0);
|
|
|
+ now.set(86399);
|
|
|
+ controller.tencentClick(request("jlt130", "c2"), new MockHttpServletResponse(), 0);
|
|
|
+ now.set(86400);
|
|
|
+ controller.tencentClick(request("jlt130", "c3"), new MockHttpServletResponse(), 0);
|
|
|
+ verify(client, times(2)).reportImpression(anyList(), anyLong(), eq(false));
|
|
|
+ verify(client, times(1)).reportClick(anyList(), eq(false));
|
|
|
+ now.set(86700);
|
|
|
+ controller.tencentClick(request("jlt130", "c4"), new MockHttpServletResponse(), 0);
|
|
|
+ verify(client, times(2)).reportClick(anyList(), eq(false));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void concurrentRequestsOnlyDeduplicateTracking() throws Exception {
|
|
|
+ ExecutorService executor = Executors.newFixedThreadPool(12);
|
|
|
+ try {
|
|
|
+ List<Callable<Void>> tasks = new ArrayList<>();
|
|
|
+ for (int n = 0; n < 30; n++) {
|
|
|
+ final int index = n;
|
|
|
+ tasks.add(() -> {
|
|
|
+ controller.tencentClick(request("jlt130", "c" + index), new MockHttpServletResponse(), 0);
|
|
|
+ return null;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ for (Future<Void> result : executor.invokeAll(tasks)) result.get();
|
|
|
+ } finally { executor.shutdownNow(); }
|
|
|
+ verify(client, times(30)).requestBid(anyString(), anyString(), any());
|
|
|
+ verify(store, times(30)).shrinkAfterClick(any());
|
|
|
+ verify(client, times(1)).reportImpression(anyList(), anyLong(), eq(false));
|
|
|
+ verify(client, times(1)).reportClick(anyList(), eq(false));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void missingDeviceAndRedisFailureSkipTrackingButKeepBiddingShrinkAndRedirect() throws Exception {
|
|
|
+ MockHttpServletRequest req = request("jlt130", "c1"); req.removeParameter("oaid");
|
|
|
+ MockHttpServletResponse first = new MockHttpServletResponse();
|
|
|
+ controller.tencentClick(req, first, 0);
|
|
|
+ assertEquals("https://example.com/landing", first.getRedirectedUrl());
|
|
|
+ when(store.claimDeviceTracking(anyString(), anyString())).thenThrow(new IllegalStateException("redis unavailable"));
|
|
|
+ MockHttpServletResponse second = new MockHttpServletResponse();
|
|
|
+ controller.tencentClick(request("jlt130", "c2"), second, 0);
|
|
|
+ assertEquals("https://example.com/landing", second.getRedirectedUrl());
|
|
|
+ verify(client, times(2)).requestBid(anyString(), anyString(), any());
|
|
|
+ verify(client, never()).reportImpression(anyList(), anyLong(), anyBoolean());
|
|
|
+ verify(client, never()).reportClick(anyList(), anyBoolean());
|
|
|
+ verify(store, times(2)).shrinkAfterClick(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void timeoutConsumesClickAllowance() throws Exception {
|
|
|
+ when(client.reportClick(anyList(), eq(false))).thenThrow(new java.io.IOException("timeout"));
|
|
|
+ assertThrows(java.io.IOException.class, () -> controller.tencentClick(request("jlt130", "c1"), new MockHttpServletResponse(), 0));
|
|
|
+ MockHttpServletResponse res = new MockHttpServletResponse();
|
|
|
+ controller.tencentClick(request("jlt130", "c2"), res, 0);
|
|
|
+ assertNotNull(res.getRedirectedUrl());
|
|
|
+ verify(client, times(1)).reportClick(anyList(), eq(false));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void cachedRecordUsesOriginalLookupAndShrinkWithoutRebiddingOrCallbackRewrite() throws Exception {
|
|
|
+ BidRecord cached = new BidRecord();
|
|
|
+ cached.setQk("cached-qk"); cached.setTagId("jlt130");
|
|
|
+ cached.setClickUrls(List.of("https://example.com/cached-click"));
|
|
|
+ cached.setLandingPage("https://example.com/cached-landing");
|
|
|
+ cached.setMediaParams(Map.of("oaid", "device-A", "callback", "original-callback"));
|
|
|
+ when(store.findBidByMediaTrace("tencent", "c1")).thenReturn(cached);
|
|
|
+ MockHttpServletRequest req = request("jlt130", "c1");
|
|
|
+ req.removeParameter("oaid"); req.setParameter("callback", "new-callback");
|
|
|
+ MockHttpServletResponse res = new MockHttpServletResponse();
|
|
|
+ controller.tencentClick(req, res, 0);
|
|
|
+ assertEquals("https://example.com/cached-landing", res.getRedirectedUrl());
|
|
|
+ assertEquals("original-callback", cached.getMediaParams().get("callback"));
|
|
|
+ verify(client, never()).requestBid(anyString(), anyString(), any());
|
|
|
+ verify(store, never()).recordBid(any());
|
|
|
+ verify(store).shrinkAfterClick(cached);
|
|
|
+ verify(client).reportClick(List.of("https://example.com/cached-click"), false);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void alreadySlimmedRecordDoesNotRestoreTrackingUrlsOrDestination() throws Exception {
|
|
|
+ BidRecord slim = new BidRecord(); slim.setQk("qk"); slim.setTagId("jlt130");
|
|
|
+ when(store.findBidByMediaTrace("tencent", "c1")).thenReturn(slim);
|
|
|
+ MockHttpServletResponse res = new MockHttpServletResponse();
|
|
|
+ controller.tencentClick(request("jlt130", "c1"), res, 0);
|
|
|
+ assertNull(res.getRedirectedUrl());
|
|
|
+ verify(store).shrinkAfterClick(slim);
|
|
|
+ verify(store, never()).claimDeviceTracking(anyString(), anyString());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void failedBidDoesNotReserveDeviceOrBlockNextBid() throws Exception {
|
|
|
+ when(client.requestBid(anyString(), anyString(), any())).thenReturn(null);
|
|
|
+ assertThrows(ResponseStatusException.class, () -> controller.tencentImpression(request("jlt130", "i1"), 0));
|
|
|
+ assertThrows(ResponseStatusException.class, () -> controller.tencentImpression(request("jlt130", "i2"), 0));
|
|
|
+ verify(client, times(2)).requestBid(anyString(), anyString(), any());
|
|
|
+ verify(store, never()).claimDeviceTracking(anyString(), anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void identitiesSupportRawAndHashAliasesWithoutUsingTraceId() {
|
|
|
+ String digest = DigestUtils.md5DigestAsHex("device-A".getBytes(StandardCharsets.UTF_8));
|
|
|
+ assertEquals(DeviceTrackingPolicy.identity(Map.of("oaid", " device-A ")),
|
|
|
+ DeviceTrackingPolicy.identity(Map.of("oaidMD5", digest.toUpperCase(Locale.ROOT))));
|
|
|
+ assertNull(DeviceTrackingPolicy.identity(Map.of("click_id", "123", "oaid", "__OAID__")));
|
|
|
+ assertNull(DeviceTrackingPolicy.identity(Map.of("oaid", "00000000-0000-0000-0000-000000000000")));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void otherPlacementsAllVersionsKeepAllUrlsAndNeverClaimDevice() throws Exception {
|
|
|
+ List<String> clicks = Arrays.asList(null, "", "bad", "https://example.com/click1", "https://example.com/click2");
|
|
|
+ for (int version = 1; version <= 6; version++) {
|
|
|
+ String suffix = version == 1 ? "" : "V" + version;
|
|
|
+ MockHttpServletRequest impression = request("other", "i" + version);
|
|
|
+ MockHttpServletRequest click = request("other", "c" + version);
|
|
|
+ // Other placements must not acquire a device requirement.
|
|
|
+ impression.removeParameter("oaid"); click.removeParameter("oaid");
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+ if (version == 1) {
|
|
|
+ controller.tencentImpression(impression, 0);
|
|
|
+ controller.tencentClick(click, response, 0);
|
|
|
+ } else {
|
|
|
+ TrackingController.class.getMethod("tencentImpression" + suffix,
|
|
|
+ jakarta.servlet.http.HttpServletRequest.class).invoke(controller, impression);
|
|
|
+ TrackingController.class.getMethod("tencentClick" + suffix,
|
|
|
+ jakarta.servlet.http.HttpServletRequest.class, jakarta.servlet.http.HttpServletResponse.class)
|
|
|
+ .invoke(controller, click, response);
|
|
|
+ }
|
|
|
+ assertEquals("https://example.com/landing", response.getRedirectedUrl());
|
|
|
+ }
|
|
|
+ verify(client, times(12)).requestBid(anyString(), anyString(), any());
|
|
|
+ verify(client, times(12)).reportImpression(List.of("https://example.com/show"), 100L);
|
|
|
+ verify(client, times(6)).reportClick(clicks);
|
|
|
+ verify(client, never()).reportImpression(anyList(), anyLong(), anyBoolean());
|
|
|
+ verify(client, never()).reportClick(anyList(), anyBoolean());
|
|
|
+ verify(store, times(12)).recordBid(any());
|
|
|
+ verify(store, times(6)).shrinkAfterClick(any());
|
|
|
+ verify(store, never()).claimDeviceTracking(anyString(), anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void legacyCachedClickNeedsNoPlacementResolution() throws Exception {
|
|
|
+ MediaPlacement placement = new MediaPlacement(); // No app/platform config: resolve would throw.
|
|
|
+ controller = new TrackingController(client, store, placement,
|
|
|
+ JsonMapper.builder().findAndAddModules().build(), "test");
|
|
|
+ BidRecord cached = new BidRecord(); cached.setQk("old-qk");
|
|
|
+ cached.setClickUrls(List.of("https://example.com/one", "https://example.com/two"));
|
|
|
+ cached.setLandingPage("https://example.com/landing");
|
|
|
+ when(store.findBidByMediaTrace("tencent", "old-click")).thenReturn(cached);
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest(); req.setParameter("click_id", "old-click");
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+ controller.tencentClick(req, response, 0);
|
|
|
+ assertEquals(cached.getLandingPage(), response.getRedirectedUrl());
|
|
|
+ verify(client).reportClick(cached.getClickUrls());
|
|
|
+ verify(client, never()).requestBid(anyString(), anyString(), any());
|
|
|
+ verify(store).shrinkAfterClick(cached);
|
|
|
+ verify(store, never()).recordBid(any());
|
|
|
+ verify(store, never()).claimDeviceTracking(anyString(), anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test void otherPlacementRetainsExistingClickBehavior() throws Exception {
|
|
|
+ controller.tencentClick(request("other", "c1"), new MockHttpServletResponse(), 0);
|
|
|
+ verify(client).reportClick(anyList());
|
|
|
+ verify(store, never()).claimDeviceTracking(anyString(), anyString());
|
|
|
+ }
|
|
|
+}
|